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).
Installation
Section titled “Installation”pip install taxqlPyPI page: pypi.org/project/taxql (publish pending — see Trial → Production).
The package depends only on httpx >= 0.24 and pydantic >= 2.0.
Quickstart
Section titled “Quickstart”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)"Client reference
Section titled “Client reference”TaxQL(api_key, *, base_url, timeout, max_retries, http_client)
Section titled “TaxQL(api_key, *, base_url, timeout, max_retries, http_client)”| Arg | Type | Default | Notes |
|---|---|---|---|
api_key | str | required | Raises ValueError if empty. |
base_url | str | "https://api.taxql.com" | Trailing slash stripped. |
timeout | float | 10.0 | Per-request, in seconds. |
max_retries | int | 3 | Retried on 429 + 5xx + timeout. |
http_client | httpx.Client | None | None | Inject 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 queryperiod: str— explicit YYYYQ period code (advanced)
client.health() -> HealthResponse
Section titled “client.health() -> HealthResponse”Hits GET /healthz. Returns HealthResponse(status="ok") when the
API is alive.
Context manager support
Section titled “Context manager support”with TaxQL(api_key="...") as client: client.lookup(state="tx", zip="75034")# httpx.Client closed automatically.AsyncTaxQL — async equivalent
Section titled “AsyncTaxQL — async equivalent”Same constructor surface, every I/O method is async:
import asynciofrom 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())Single-row vs multi-row responses
Section titled “Single-row vs multi-row responses”This is the most important section. The API can return one row or many — the SDK gives you both ergonomic and explicit access patterns.
Single-row (the common case)
Section titled “Single-row (the common case)”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 # ResolvedPlaceprint(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.
Multi-row (ZIP-mode ambiguity)
Section titled “Multi-row (ZIP-mode ambiguity)”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_rateexcept 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”).
Why no automatic picking?
Section titled “Why no automatic picking?”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.
Error handling
Section titled “Error handling”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_rateexcept 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})")Retries
Section titled “Retries”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.
Models reference
Section titled “Models reference”All response models live in taxql.models and are re-exported from
the top level:
TaxResponse— top-level lookup response withrows: list[TaxRow],warnings: list[str],billing: BillingInfo, plus convenience propertiescombined_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.BillingInfo—tier+ post-callcredits.HealthResponse—status.
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().
Async concurrency pattern
Section titled “Async concurrency pattern”import asynciofrom 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.
Source
Section titled “Source”The package source lives at
github.com/taxql/taxql-python
(repo pending — currently in-repo at services/sdk/python/).
Changelog
Section titled “Changelog”See changelog.