countrystatecity-api
The official Python client for the Country State City API. Continuously updated geographic data, with sync and async access, typed payloads, and structured errors.
60 seconds to your first request
pip install countrystatecity-api
Create a free API key (no card required), then:
export CSC_API_KEY="your-api-key"
from countrystatecity import CountryStateCity
csc = CountryStateCity() # reads CSC_API_KEY
india = csc.get_country("IN")
print(india["emoji"], india["name"], "-", india["capital"])
for state in csc.get_states_of_country("IN"):
print(state["iso2"], state["name"])
That is the whole setup. Reuse one client for the life of your process; it holds a connection pool.
Async
The same surface, without blocking the event loop:
import asyncio
from countrystatecity import AsyncCountryStateCity
async def main() -> None:
async with AsyncCountryStateCity() as csc:
country, states = await asyncio.gather(
csc.get_country("IN"),
csc.get_states_of_country("IN"),
)
print(country["name"], len(states), "states")
asyncio.run(main())
Both clients take the same arguments, expose the same method names, and return the same payloads. A test asserts their signatures stay identical.
Handling failures
Every failure is a subclass of CountryStateCityError, so one except clause
contains the client. Branch further when you want to act on the reason:
from countrystatecity import (
CountryStateCity,
APITimeoutError,
NotFoundError,
PermissionDeniedError,
RateLimitError,
)
csc = CountryStateCity(timeout=10.0)
try:
cities = csc.get_cities_of_state("IN", "MH", q="pune")
except NotFoundError:
cities = []
except PermissionDeniedError as exc:
# Endpoint or query feature not included in this plan.
print(f"{exc.feature} requires an upgrade: {exc.upgrade_url}")
except RateLimitError as exc:
print(f"{exc.period} limit of {exc.limit} reached on the {exc.tier} plan")
print(f"Resets at {exc.reset_at}") # ISO 8601 UTC, or None
print(f"Raise it at {exc.upgrade_url}")
except APITimeoutError:
... # retry on your own schedule; see "No implicit retries" below
| Exception | Raised for |
|---|---|
ConfigurationError |
Missing/blank key, bad base URL, non-finite timeout — at construction |
ValidationError |
An argument the API would reject; raised before the request is sent |
APIConnectionError |
DNS, TLS, refused connection, truncated response |
APITimeoutError |
Request exceeded the timeout (subclass of APIConnectionError) |
BadRequestError |
400 |
AuthenticationError |
401 — key missing, malformed, or unknown |
PermissionDeniedError |
403 — plan restriction, or a domain/IP allow-list block |
NotFoundError |
404 |
RateLimitError |
429 — daily or monthly quota exhausted |
ServerError |
5xx |
APIResponseError |
A 2xx body that was not valid JSON |
APIStatusError (the parent of the status classes) always carries
.status_code, .details, .method, and .url. .details normalises both
error envelopes the API emits, so feature, upgradeUrl, tier, limit, and
period are readable regardless of which layer rejected the request.
Plan and quota headers
Every /v1 response reports your tier and usage. Reach them through the
low-level request() method, which returns the payload and its metadata:
response = csc.request("/countries")
print(response.meta.plan) # 'supporter'
print(response.meta.daily.used,
response.meta.daily.limit,
response.meta.daily.remaining) # 42 1000 958
print(response.meta.cache) # 'HIT' or 'MISS'
print(response.meta.etag)
countries = response.data
request() doubles as an escape hatch for any endpoint this version does not
wrap yet.
For unlimited plans, daily.unlimited is True and limit/remaining are
None. On a 401 or 429 the API rejects the request before setting those
headers, so metadata is empty there — a RateLimitError's .limit, .period,
.tier, and .reset_at come from the response body instead.
Search, field selection, and sorting
Paid plans add server-side query features. Ask for less data and the responses get much smaller:
# Inline search
csc.get_cities_of_country("IN", q="pune")
# Only the fields you use
csc.get_countries(fields=["id", "name", "iso2", "emoji"])
# Server-side ordering
csc.get_countries(sort="population:desc")
# Typo-tolerant search
csc.fuzzy_search("bangalor", entity="city", country="IN", limit=5)
fields and sort accept a comma-separated string or a list of strings.
Requesting a feature your plan does not include raises PermissionDeniedError
with the exact feature name and an upgrade URL.
Postcode lookup and search
Exact postcode lookup is free on every plan, including Community. A postcode can map to more than one locality, so it always returns a list -- never just the first match:
matches = csc.get_postcodes_by_code("GB", "SW1A 1AA")
for postcode in matches:
print(postcode["locality_name"], postcode.get("state_code"))
Paginated listing and search adds filters, but requires a Supporter plan or above:
page = csc.get_postcodes_of_country("GB", q="SW1A", state_code="ENG", limit=50)
for postcode in page["data"]:
print(postcode["code"], postcode.get("locality_name"))
if page["pagination"]["has_more"]:
page = csc.get_postcodes_of_country(
"GB", q="SW1A", cursor=page["pagination"]["next_cursor"]
)
cursor is opaque -- always pass back the exact value from
page["pagination"]["next_cursor"], never one you construct yourself. There
is no total count in the response; keep paging while has_more is True.
Both postcode methods accept only an ISO 3166-1 alpha-2 country code -- unlike every other country-scoped method on this client, ISO3 codes and numeric ids are not accepted here.
Compare plans to unlock search.
API reference
All methods issue one HTTP GET. country accepts an ISO 3166-1 alpha-2 code,
an alpha-3 code, or a numeric country id -- except the postcode methods, which
accept alpha-2 only.
Countries, states, cities
| Method | Endpoint |
|---|---|
get_countries(q=, fields=, sort=) |
GET /countries |
get_country(country, fields=) |
GET /countries/{ciso} |
get_states(q=, fields=, sort=) |
GET /states |
get_states_of_country(country, q=, fields=, sort=) |
GET /countries/{ciso}/states |
get_state(country, state, fields=) |
GET /countries/{ciso}/states/{siso} |
get_cities_of_country(country, q=, fields=, sort=) |
GET /countries/{ciso}/cities |
get_cities_of_state(country, state, q=, fields=, sort=) |
GET /countries/{ciso}/states/{siso}/cities |
Regions
| Method | Endpoint |
|---|---|
get_regions(q=, fields=, sort=) |
GET /regions |
get_region(region_id, fields=) |
GET /regions/{id} |
get_subregions_of_region(region_id, q=, fields=, sort=) |
GET /regions/{id}/subregions |
get_subregion(subregion_id, fields=) |
GET /subregions/{id} |
get_countries_of_subregion(subregion_id, q=, fields=, sort=) |
GET /subregions/{id}/countries |
Timezones, currencies, phone
| Method | Endpoint |
|---|---|
get_timezone_of_country(country) |
GET /timezone/{ciso} |
get_timezone_of_state(country, state) |
GET /timezone/{ciso}/{siso} |
get_timezone_of_city(country, state, city_id) |
GET /timezone/{ciso}/{siso}/{city_id} |
get_currencies(code=) |
GET /currency |
get_currency_of_country(country) |
GET /currency/{ciso} |
get_dial_codes(code=) |
GET /phone |
get_dial_code_of_country(country) |
GET /phone/{ciso} |
parse_phone_number(number) |
GET /phone/parse |
ISO lookup and search
| Method | Endpoint |
|---|---|
lookup_country_iso(iso2=, iso3=, numeric=) |
GET /iso/country |
lookup_state_iso(iso) |
GET /iso/state |
convert_country_code(value, from_format=, to_format=) |
GET /iso/country/convert |
fuzzy_search(query, entity=, country=, limit=, threshold=) |
GET /search/fuzzy |
request(path, params=) |
Any GET under the base URL |
Postcodes
| Method | Endpoint |
|---|---|
get_postcodes_by_code(country, code, fields=) |
GET /countries/{iso2}/postcodes/{code} |
get_postcodes_of_country(country, q=, state_code=, city_id=, type=, limit=, cursor=, fields=) |
GET /countries/{iso2}/postcodes |
Full endpoint reference: docs.countrystatecity.in · Try it live: playground.countrystatecity.in
Types
Payloads are plain dicts. countrystatecity.types describes their shape with
TypedDicts — Country, State, City, Region, Subregion,
TimezoneInfo, CurrencyInfo, DialCode, PhoneParsed, IsoCountry,
IsoState, IsoConvert, FuzzyResult, Postcode, PostcodePagination, and
PostcodeSearchResult.
Every field is declared optional, because which fields arrive depends on your
plan's data-access level and on the fields parameter. total=False describes
presence, not value: when a key is there, its declared type is what you get.
The type checker therefore treats every key as possibly missing, id and
name included. Read the ones outside your plan's guaranteed set with .get(),
and assert the ones your plan does guarantee at your own boundary:
country = csc.get_country("IN")
name = country["name"] # present on every plan; a KeyError
# here means the API changed
population = country.get("population") # coordinates tier and above
Ids are strings. Every id, foreign id, population, and gdp is a 64-bit
BIGINT in the API's database, and the API serialises those as JSON strings —
a bigint does not survive a round trip through a JavaScript number. So
country["id"] == "101", not 101. Convert with int() where you need
arithmetic. level, area_sq_km, and match_score are ordinary numbers.
The package ships py.typed, so mypy and Pyright see these types with no stub
package.
Behaviour worth knowing
Your key stays a secret. It is sent only in the X-CSCAPI-KEY header,
never in a URL. It does not appear in repr(client), in exception messages, or
in APIStatusError.url. Keep it in a server-side environment variable — never
in browser code, a mobile app, or source control.
Request failures are safe to log. APIStatusError.url and transport error
messages carry the scheme, host, and path only — the query string and fragment
are dropped. Raw-path and query-parameter validation errors do not repeat the
rejected input. Query values are your data: parse_phone_number() sends a
phone number and q= sends a search term, and a failure should not put either
into your logs. The request itself is unaffected; only what the exception
records is trimmed.
The request() escape hatch stays under the base URL. //host, an
embedded ? or #, and ./.. segments (including percent-encoded ones) are
rejected with ValidationError, so a path built from untrusted input cannot
walk out of /v1 and reach another route.
Nothing happens at import. Importing the package and constructing a client open no connections. The first request is the one you make.
No implicit retries. A silent retry would spend a second request from your
quota without you asking. Add your own policy where you want one, and back off
on RateLimitError — the free Community plan is a small daily allowance.
Finite timeouts. The default is 30 seconds per request. timeout=None is
rejected: an unbounded read can wedge a worker forever.
Arguments are validated locally. Country codes, state codes, ids, search
terms, field lists, and phone numbers are checked against the same rules the API
enforces, so a malformed call raises ValidationError instead of spending a
request on a guaranteed 400. Field names are checked for shape only — the
API stays the authority on which columns exist for your plan.
No telemetry. The only thing this package reports about itself is a standard
User-Agent string.
Offline packages
The countrystatecity-* packages are versioned offline snapshots — no network,
no key, no quota. They suit development, tests, air-gapped builds, and anything
that must be reproducible.
| Package | Contents |
|---|---|
| countrystatecity-countries | Countries, states, cities |
| countrystatecity-timezones | IANA timezones and conversion |
| countrystatecity-currencies | Country/currency associations |
| countrystatecity-translations | Country names in 19 languages |
| countrystatecity-phonecodes | International dialing codes |
| countrystatecity-regions | Regions and subregions |
| countrystatecity-postal-codes | Postal/ZIP records and validation |
Use this client instead when you need data that is current rather than pinned, server-side search and filtering, field-selected responses, fuzzy matching, managed availability, or support.
Migration guide · Compare plans
Requirements
Python 3.8+ and httpx, which provides both the sync and async transports.
License
ODbL-1.0 — see LICENSE. The geographic data this client retrieves comes from countries-states-cities-database.
Made with ❤️ by dr5hn
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file countrystatecity_api-0.2.0.tar.gz.
File metadata
- Download URL: countrystatecity_api-0.2.0.tar.gz
- Upload date:
- Size: 44.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e88ff9537be4f7f7adbca2c5119f57584a6ebb8c6745f891e9368c60e12c959
|
|
| MD5 |
17778bc9b1579075eb8b9987056ec3c0
|
|
| BLAKE2b-256 |
951a05ada1ddb6f640d63d1cd8627faaf939b88f181f07e372f5b5024b95485e
|
File details
Details for the file countrystatecity_api-0.2.0-py3-none-any.whl.
File metadata
- Download URL: countrystatecity_api-0.2.0-py3-none-any.whl
- Upload date:
- Size: 47.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a03fd87206d6e0434be259702a7dfb26a6c232e3e64ebaeae9a9de82ae92b868
|
|
| MD5 |
f8910bcd1674673ca4fc53c37298171a
|
|
| BLAKE2b-256 |
deb9c55858f9af0298adcca498bb1dae7b3f6e6bb9837912dd458351e468a9f2
|