Zeep returns an empty list: a repeated xs:sequence lands in _value_1
A SOAP client listed received messages and got an empty list on every call, even though the mailbox had messages. No exception or error status occurred, and the unit tests passed. This was the line that read the response:
records = getattr(response, "dmRecords", None)
page = (records and getattr(records, "dmRecord", None)) or []
The XML on the wire had <dmRecords><dmRecord>…</dmRecord><dmRecord>…</dmRecord></dmRecords>, but zeep never created a dmRecord attribute. It put the records in dmRecords._value_1.
If zeep gives you None or an empty list for a field you can see in the raw response, check whether the schema repeats the sequence rather than the element.
Setup #
An Odoo 18 addon pulls messages from the Czech Data Box system (ISDS), a government SOAP service with published WSDL and XSD files, and talks to it through zeep. I reproduced the behavior below with zeep 4.3.3 on Python 3.13. The list type in the ISDS schema looks like this:
<xs:complexType name="tRecordsArray">
<xs:sequence minOccurs="0" maxOccurs="unbounded">
<xs:element name="dmRecord" type="tns:tRecord" />
</xs:sequence>
</xs:complexType>
maxOccurs="unbounded" sits on the xs:sequence, where you would usually expect it on the xs:element.
What went wrong #
zeep maps the two forms differently. Here is a minimal schema with both, parsed with zeep’s own types:
<xs:complexType name="tRepeatedSequence">
<xs:sequence minOccurs="0" maxOccurs="unbounded">
<xs:element name="record" type="tns:tRecord"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="tRepeatedElement">
<xs:sequence>
<xs:element name="record" type="tns:tRecord" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
Feed both the same two <record> children, then call serialize_object(..., dict):
tRepeatedSequence: {'_value_1': [{'record': {'id': '1'}}, {'record': {'id': '2'}}]}
tRepeatedElement: {'record': [{'id': '1'}, {'id': '2'}]}
With a repeated element, you get obj.record as a list. With a repeated sequence, each repetition becomes an item. The sequence has no name, so zeep calls the list _value_1, and each item is a dict keyed by the element names inside the sequence. A sequence with two elements would give you {"a": …, "b": …} per item.
The zeep docs explain _value_1 only under xs:choice and xs:any. Nothing there tells you that a plain repeated sequence ends up there too.
Plain attribute access would have failed loudly on the first real call:
AttributeError: tRepeatedSequence instance has no attribute 'record'
The getattr(..., None) or [] was written so that an empty mailbox wouldn’t crash. It also handled the case where the attribute doesn’t exist at all, and turned it into “no messages”.
The tests didn’t catch it because they replaced the client’s methods with mock.patch. They never sent a SOAP response through zeep, so they never saw how zeep shapes it.
Fix #
Read _value_1 and unwrap each item:
records = getattr(response, "dmRecords", None)
page = [item["dmRecord"] for item in (getattr(records, "_value_1", None) or [])]
An empty <dmRecords/> still gives _value_1 == [], so the empty-mailbox case works.
To see the shape before you write the parsing code, dump the WSDL with zeep’s CLI:
python -m zeep dm_info.wsdl
ns0:tRecordsArray([dmRecord: ns0:tRecord])
The square brackets give it away. A repeated sequence prints with brackets around the field list. A repeated element, like tRepeatedElement above, prints with [] after the type instead: ns0:tRepeatedElement(record: ns0:tRecord[]).
Test the parsing with real XML #
The test that covers this now runs canned SOAP XML through the real zeep client, with only the HTTP call patched:
def response(xml_body):
result = requests.Response()
result.status_code = 200
result.headers["Content-Type"] = "text/xml; charset=utf-8"
result._content = soap_envelope(xml_body)
return result
with mock.patch.object(requests.Session, "post", side_effect=[response(listing)]):
envelopes = client.list_received_messages(since, until)
self.assertEqual([e["id"] for e in envelopes], ["100001", "100002"])
Zeep still loads the WSDL, parses the envelope, and builds its objects, so a mapping mistake like this fails in the test suite with no network. Write the XML from the schema or from a captured response.
The same commit fixed a second or default with the same problem: return response.dmSignature or b"" would have stored a message without its signed copy. It now raises when the signature is missing.
Takeaway #
zeep maps a repeated xs:sequence to _value_1, a list of dicts keyed by the inner element names. A getattr(..., None) or [] on top turns that into a silent empty result. Let missing attributes raise, and test SOAP parsing with real XML through zeep.
Search your zeep code for getattr( with a None default followed by or [], and check each field against the python -m zeep dump of your WSDL.