Skip to content

Python SDK (taxql)

The official Python client for the TaxQL API. Hand-written, sync + async via httpx, typed responses via Pydantic v2, automatic retries on 429 and 5xx with exponential backoff (honoring Retry-After headers).

Terminal window
pip install taxql

PyPI page: pypi.org/project/taxql (current version 0.2.1).

The package depends only on httpx >= 0.24 and pydantic >= 2.0.

from taxql import TaxQL
client = TaxQL(api_key="your-key-here")
response = client.lookup(
state="tx",
address="500 W 5th St, Austin, TX 78701",
)
print(response.combined_rate) # 0.0825
print(response.confidence) # "exact"
print(response.rate.county) # "TRAVIS"

TaxQL(api_key, *, base_url, timeout, max_retries, http_client)

Section titled “TaxQL(api_key, *, base_url, timeout, max_retries, http_client)”
ArgTypeDefaultNotes
api_keystrrequiredRaises ValueError if empty.
base_urlstr"https://api.taxql.com"Trailing slash stripped.
timeoutfloat10.0Per-request, in seconds.
max_retriesint3Retried on 429 + 5xx + timeout.
http_clienthttpx.Client | NoneNoneInject your own — SDK won’t close it.

client.lookup(state, *, ...) -> TaxResponse

Section titled “client.lookup(state, *, ...) -> TaxResponse”

Exactly one input mode per call:

client.lookup(state="tx", address="...", city="...", zip="...")
client.lookup(state="wa", zip="98039")
client.lookup(state="wa", location="Seattle")
client.lookup(state="ca", lat=34.05, lng=-118.25)

Optional:

  • as_of: date — historical query
  • period: str — explicit YYYYQ period code (advanced)

Hits GET /healthz. Returns a HealthResponse with a status string when the API is alive.

with TaxQL(api_key="...") as client:
client.lookup(state="tx", zip="75034")
# httpx.Client closed automatically.

Same constructor surface, every I/O method is async:

import asyncio
from taxql import AsyncTaxQL
async def main():
async with AsyncTaxQL(api_key="...") as client:
return await asyncio.gather(
client.lookup(state="tx", zip="75034"),
client.lookup(state="wa", zip="98039"),
client.lookup(state="ca", zip="94110"),
)
asyncio.run(main())

A lookup returns the single applicable rate — the convenience properties give you the common answers, and response.rate / response.meta hold the rest:

response = client.lookup(state="tx", address="500 W 5th St, Austin, TX 78701")
response.combined_rate # float accessor — total rate, e.g. 0.0825
response.combined_rate_string # "0.08250" — the lossless wire string
response.confidence # "exact" | "high" | "medium" | "low" | "none"
response.warnings # list of advisories (see below)
response.rate.state # "TX"
response.rate.county # "TRAVIS"
response.rate.state_rate # "0.06250" — wire STRING (since 0.2.0)
response.rate.state_rate_as_float() # 0.0625 — explicit accessor
response.rate.combined_district_rate # "0.02000" — sum of special-district rates
response.meta.resolved_via # "address_locator"
response.meta.effective_date # "2025-03-01" or None — the served rate's
# legal-effect date; None unless the source
# publishes a genuine per-row effective date
response.meta.effective_date_basis # "publisher_effective_date" | "edition_only"
# | "no_published_date" (provenance)
response.meta.period # DEPRECATED: quarter code OR ISO date OR None;
# prefer effective_date + effective_date_basis

Since 0.2.0, rate fields are preserved as the API’s fixed 5-decimal strings ("0.06250") — never coerced to float — so binary floating point never touches tax arithmetic. Convert explicitly with rate_to_float(...), the Rate.*_as_float() accessors, or response.combined_rate (a float accessor); for exact money math read the string and use decimal.Decimal. This matches the PHP and Node SDKs. For the verbose per-jurisdiction body (every candidate row, component breakdown, resolved place), request mode=full on the raw HTTP API and read it via response.model_dump().

response.warnings is a list whose items are either plain strings or structured objects carrying a code:

for w in response.warnings:
if isinstance(w, dict) and w.get("code") == "place_input_mismatch":
print(f"input '{w['customer_input_place']}' resolved to "
f"'{w['resolved_place_name']}'")
elif isinstance(w, str):
print(w)
from taxql import (
TaxQLError, # Base — catch this for "any SDK error"
AuthError, # 401 — invalid/missing API key
PaymentRequiredError,# 402 — billing/subscription issue
ForbiddenError, # 403 — your plan doesn't include this
RateLimitError, # 429 — has .retry_after attribute
NotFoundError, # 404 — address/ZIP couldn't be resolved
ValidationError, # 400/422 — request invalid
ServiceError, # 5xx — upstream issue
)
try:
response = client.lookup(state="tx", address="...")
rate = response.combined_rate
except AuthError:
print("Bad API key — check the dashboard")
except RateLimitError as exc:
print(f"Rate limited; SDK already retried — give up after {exc.retry_after}s")
except NotFoundError:
print("Address could not be resolved — try ZIP fallback")
except ServiceError:
print("Upstream is down — retry later")
except TaxQLError as exc:
print(f"Unexpected SDK error: {exc} (status={exc.status_code})")

Every exception carries status_code, the machine error_code, the support_reference (quote it to support), and the parsed response_body:

except TaxQLError as exc:
print(exc.error_code, exc.support_reference, exc.status_code)

429 and 5xx responses are retried automatically (default max_retries=3) with exponential backoff. If the response includes a Retry-After header, the SDK honors it; otherwise the delay is min(2 ** attempt * 0.5, 30) seconds — 0.5s, 1s, 2s, 4s, 8s, then capped at 30.

4xx errors other than 429 raise immediately. There’s no point retrying a malformed request.

All response models live in taxql.models and are re-exported from the top level:

  • TaxResponse — top-level lookup response: rate: Rate, meta: Meta, plus convenience properties combined_rate / confidence / warnings.
  • Rate — the resolved rate: state, zip, city, county, country, country_rate (national-level rate; 0 for the US), state_rate, county_rate, city_rate, combined_district_rate, combined_rate, freight_taxable.
  • Meta — diagnostics: resolved_via, confidence, period, warnings.
  • HealthResponsestatus.

All models use extra="allow", so newer API fields don’t break older clients. Reach anything not surfaced as a typed attribute via response.model_dump().

import asyncio
from taxql import AsyncTaxQL
async def lookup_many(api_key: str, queries: list[dict]):
async with AsyncTaxQL(api_key=api_key) as client:
results = await asyncio.gather(
*(client.lookup(**q) for q in queries),
return_exceptions=True,
)
return results
# Use:
queries = [
{"state": "tx", "address": "500 W 5th St, Austin, TX 78701"},
{"state": "wa", "zip": "98039"},
{"state": "ca", "lat": 34.05, "lng": -118.25},
]
results = asyncio.run(lookup_many("your-key", queries))

return_exceptions=True keeps one failed lookup from cancelling the others. Walk results and branch on isinstance(r, Exception) for each entry.

The package source lives at github.com/taxql/taxql-python (repo pending — currently in-repo at services/sdk/python/).

See changelog.