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 (publish pending — see Trial → Production).

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="123 Barton Creek Lane",
city="Frisco",
zip="75068",
)
print(response.combined_rate) # 0.065 (6.5%)
print(response.resolved_place.name) # "Denton County (unincorporated)"

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 HealthResponse(status="ok") 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())

This is the most important section. The API can return one row or many — the SDK gives you both ergonomic and explicit access patterns.

Most lookups return exactly one row. Use the convenience properties:

response = client.lookup(
state="tx",
address="6515 Cottonwood Creek Dr",
city="Frisco",
zip="75034",
)
# Just want the number?
print(response.combined_rate) # 0.0825
# Need the jurisdiction details?
place = response.resolved_place # ResolvedPlace
print(place.name, place.place_type, place.county)

combined_rate reads response.rows[0].combined_total_rate; resolved_place reads response.rows[0].resolved_place. Both raise or return None when there’s no unambiguous single row — see below.

When a ZIP straddles jurisdictions, the response has multiple rows and the convenience properties refuse to guess:

from taxql import AmbiguousLookupError
response = client.lookup(state="wa", zip="98039")
try:
rate = response.combined_rate
except AmbiguousLookupError as exc:
print(f"{response.row_count} candidates — ambiguous: {exc}")
for row in response.rows:
print(f" {row.location:25s} {row.combined_total_rate * 100:.4f}% "
f"(effective {row.effective_from} .. {row.effective_to})")

The pattern: when you hit AmbiguousLookupError, either (a) re-query in address mode if you have the full address, or (b) apply your own business rule to pick a row (e.g., “highest allocation pct” for TX ZIP-mode rows, or “treat all rows as a range and surface to the user”).

We considered defaulting combined_rate to “first row” or “highest rate” — both are wrong in different scenarios. ZIP-overlap ambiguity is a real signal that the customer’s input wasn’t precise enough; silently picking obscures that. Surface it instead.

from taxql import (
TaxQLError, # Base — catch this for "any SDK error"
AuthError, # 401 — invalid/missing API key
RateLimitError, # 429 — has .retry_after attribute
NotFoundError, # 404 — address/ZIP couldn't be resolved
ValidationError, # 400/422 — request invalid
ServiceError, # 5xx — upstream issue
AmbiguousLookupError, # Multi-row .combined_rate / .resolved_place access
)
try:
response = client.lookup(state="tx", address="...", city="...", zip="...")
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 AmbiguousLookupError:
# Walk rows manually
...
except ServiceError:
print("Upstream is down — retry later")
except TaxQLError as exc:
print(f"Unexpected SDK error: {exc} (status={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 with rows: list[TaxRow], warnings: list[str], billing: BillingInfo, plus convenience properties combined_rate / primary_row / resolved_place.
  • TaxRow — a single candidate rate row (location, state_rate, combined_total_rate, components, effective_from/to, resolved_place).
  • Components — per-bucket breakdown (state, county, city, special, combined, components_complete).
  • ResolvedPlace — jurisdiction metadata (name, place_type, incorporated, jurisdiction_code, county).
  • PlaceComponent — TX-style jurisdiction within a row.
  • BillingInfotier + post-call credits.
  • HealthResponsestatus.

All models use extra="allow", so newer API versions adding fields don’t break older SDK clients. Reach state-specific fields via response.model_dump() or row.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": "123 Main St", "city": "Frisco", "zip": "75068"},
{"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.