Calling a SOAP Service From Python With zeep and a WSDL
Call a SOAP service from Python with zeep: load the WSDL, call an operation, get typed results, handle a Fault, see the request envelope,.

Reading a SOAP response by hand (reading a SOAP response and pulling the fields you need) is fine for one field. For a real integration, with a WSDL that describes the operations and the types, a generated client is better. In Python the usual one is zeep. It reads the WSDL, builds the Python methods and types, validates what you send and converts what comes back.
The WSDL in two minutes
A WSDL describes a service: its operations, the XML types of their messages and where to send them. Mine defines one operation:
<xsd:element name="GetPrice"><xsd:complexType><xsd:sequence><xsd:element name="sku" type="xsd:string"/></xsd:sequence></xsd:complexType></xsd:element>
<xsd:element name="GetPriceResponse"><xsd:complexType><xsd:sequence>
<xsd:element name="Price" type="tns:Money"/>
<xsd:element name="InStock" type="xsd:boolean"/>
<xsd:element name="Tag" type="xsd:string" minOccurs="0" maxOccurs="unbounded"/>
</xsd:sequence></xsd:complexType></xsd:element>
<xsd:complexType name="Money"><xsd:simpleContent><xsd:extension base="xsd:decimal">
<xsd:attribute name="currency" type="xsd:string"/></xsd:extension></xsd:simpleContent></xsd:complexType>The request has a sku. The response has a Price (a decimal with a currency attribute), a boolean InStock and zero or more Tag strings. A real WSDL has dozens of operations and hundreds of types, and a client library is how you avoid writing them by hand.
Calling it
from zeep import Client
c = Client("http://localhost:8124/?wsdl")
print("operations:", [name for s in c.wsdl.services.values() for p in s.ports.values() for name in p.binding._operations])
r = c.service.GetPrice(sku="A-1")
print(type(r).__name__, "| Price:", repr(r.Price), "| InStock:", r.InStock, type(r.InStock).__name__, "| Tag:", r.Tag)operations: ['GetPrice']
GetPriceResponse | Price: {
'_value_1': Decimal('19.99'),
'currency': 'USD'
} | InStock: True bool | Tag: ['lamp', 'desk']Compare this with the manual reader. The Client loaded the WSDL from the service's URL and exposed c.service.GetPrice(sku=...) as an ordinary method call. The result is a GetPriceResponse object, and its fields have Python types because the schema said so: InStock is a real bool, Tag is a list (empty if there are none, as the second SKU showed), and Price is a structure with the amount as a Decimal and the currency attribute. The unusual name _value_1 is zeep's way to name the text of an element that also has attributes; there is no better name in the schema. zeep.helpers.serialize_object(r) turns the result into plain dictionaries, which is what you pass to JSON or a database layer.
You never touched a namespace, an envelope or a prefix. That is the point of the client, and also the reason to look at the envelope at least once, to know what it sends.
Faults and validation
try: c.service.GetPrice(sku="ZZ")
except Fault as e: print("Fault:", e.message, "| code:", e.code)
try: c.service.GetPrice()
except Exception as e: print("missing arg:", type(e).__name__, str(e)[:70])Fault: Unknown SKU ZZ | code: soap:Client
missing arg: ValidationError Missing element sku (GetPrice.sku)The service answered the unknown SKU with a SOAP fault (HTTP 500 with a fault envelope), and zeep turned that into a zeep.exceptions.Fault with the faultstring as message and the faultcode as code. That means error handling is a normal try/except and the code can branch on e.code (client fault or server fault), as discussed in SOAP responses and pulling the payload out. The second case is client-side validation: calling GetPrice() with no sku raised ValidationError: Missing element sku (GetPrice.sku) and nothing was sent. The WSDL says sku is required, and zeep enforces it before the network call. This catches a class of bugs early, and it can also surprise you when a service's WSDL is stricter than the service itself; zeep has settings to relax it (Settings(strict=False)), and you should know why before using them.
What went on the wire
The test server saved the request it received:
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
<soap-env:Body>
<ns0:GetPrice xmlns:ns0="urn:shop">
<ns0:sku>ZZ</ns0:sku>
</ns0:GetPrice>
</soap-env:Body>
</soap-env:Envelope>A standard SOAP 1.1 envelope, with the operation in the urn:shop namespace under the generated prefix ns0. The prefix does not matter (only the URI). For debugging an integration, zeep has a HistoryPlugin that keeps the last sent and received envelopes:
from zeep.plugins import HistoryPlugin
h = HistoryPlugin()
c2 = Client("http://localhost:8124/?wsdl", plugins=[h])
c2.service.GetPrice(sku="A-1")
etree.tostring(h.last_received["envelope"])The received envelope was 251 bytes. Log both, with credentials and personal data removed, whenever a call fails: when a vendor says "your request was wrong", the envelope is the evidence for or against, and the same applies the other way round.
Practical points
Load the WSDL once. Parsing a large WSDL takes a noticeable time. Create the Client at start-up, share it, and give it a Transport with a cache and a timeout (zeep.transports.Transport(cache=SqliteCache(), timeout=10)); otherwise a hung service blocks the caller.
Do not fetch the WSDL from a live endpoint in production if you can avoid it. Save the WSDL and its schemas in the repository and load from the file, so a change on the vendor's side does not change your client without a deploy.
Authentication. WS-Security, basic auth over HTTPS and client certificates are all supported through plugins or the transport's session; the details depend on the service. I did not test them here.
Types. Decimals come back as Decimal, dates as datetime.date or datetime, and nils as None. If the service uses xsd:any or open content, you get raw lxml elements for those parts.
Namespaces and bindings. If a WSDL has several bindings (SOAP 1.1 and 1.2) or ports, pick one with client.create_service(binding_name, address).
Security. zeep's Settings has forbid_dtd, forbid_entities and forbid_external flags for parsing responses. As I read its documentation, entities and external references are forbidden by default and DTDs are allowed, so set forbid_dtd=True for a service that has no business sending one; I did not test the flags. Do not point a client at a WSDL you do not trust, since a WSDL can import other documents by URL (XML security problems to close before production).
Testing against a stub
The local service in this article is also the pattern for tests. Keep a small stub that implements the operations you call, returns canned responses (a normal one, one with a list, one with a nil, a fault), and runs in CI. The integration code then runs against the stub with the real WSDL, so a WSDL change that breaks your calls fails the build. Record real responses from the vendor's test environment once, strip anything private, and replay them. A test that calls the vendor's live service on every build is slow and fails for reasons that are not yours.
Related reading
For reading a response without a client see reading a SOAP response and pulling the fields you need, and for the overview working with XML feeds and APIs. The library is documented at docs.python-zeep.org, and the format in the WSDL 1.1 note. Test a path in the XPath tester.
When zeep is more than the integration needs
One field from one response. A direct HTTP call and an XPath are shorter, and have fewer dependencies.
The service is not really SOAP. Plain XML over HTTP (POX) or REST with XML bodies needs requests and lxml, not a WSDL client.
The WSDL is broken or huge. Some vendor WSDLs do not load in any client. Build the envelope by hand from the vendor's sample and keep the sample as a test.
You need streaming or attachments. MTOM and large attachments are special; check the library's support before relying on it.


