Import country, state and city CSV into SQLite.
Use one pinned snapshot, import parent tables first, preserve location IDs and keep null state links. This recipe builds a local SQLite database with the Python standard library and checks its relationships before publishing the file.
Tested against release v5.2.0-export.1. The counts below describe that snapshot. The import needs Python 3.9+ with sqlite3; the download commands use curl and a POSIX shell.
1. Choose the format for your task.
CSV is useful for a small, explicit SQL projection or an ETL pipeline. Here, countries retain id, name, iso2; states retain id, name, country_id, iso2; cities retain id, name, country_id, state_id. A separate import_info table records the release and source commit. The shipped importer accepts this pinned snapshot; custom or filtered files need their own verified hashes and required parent rows.
The example deliberately leaves other columns out. For a ready-made database export, select the matching engine from the same release:
This guide validates the CSV-to-SQLite path. Review the release notes and target-engine tooling before loading a different export into an empty database.
2. Download one reproducible snapshot.
Countries and states are reference CSVs from the release commit. Cities come from the matching compressed release asset. The importer checks all three file sizes and SHA-256 hashes before reading CSV rows. Keep the city file compressed.
set -eu
mkdir csc-csv-demo
cd csc-csv-demo
curl -fL 'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/9b255c45fc53e02a8dff547d88558d4fe91784d5/csv/countries.csv' -o countries.csv
curl -fL 'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/9b255c45fc53e02a8dff547d88558d4fe91784d5/csv/states.csv' -o states.csv
curl -fL 'https://github.com/dr5hn/countries-states-cities-database/releases/download/v5.2.0-export.1/csv-cities.csv.gz' -o cities.csv.gz
curl -fL 'https://countrystatecity.in/examples/import-csc-csv.py' -o import-csc-csv.pyThe command creates a new csc-csv-demo directory and stops if it already exists or a download fails. The pinned source commit is 9b255c45fc53e02a8dff547d88558d4fe91784d5. Source links: countries CSV, states CSV, compressed cities CSV.
3. Build a new local database.
From the new directory, run the importer. It preserves IDs, imports countries before states and cities, and enables SQLite foreign keys. The composite state relationship also checks that a city and its selected state belong to the same country.
python3 import-csc-csv.py . locations.sqliteCSV values are read with quoting-aware UTF-8 parsing and bound SQL parameters. Blank city state_id values become SQL NULL. Invalid identifiers, duplicate keys and broken relationships stop the import. An existing output file is preserved.
The importer builds in a private temporary directory beside the output, then publishes with a same-filesystem hard link. This requires a filesystem that supports hard links. A failure leaves no newly published database; an output created by another process during the import is also preserved.
Download the Python importerView the complete importer and pinned checksums
#!/usr/bin/env python3
"""Import a verified CSC CSV snapshot into a new, local SQLite projection."""
import argparse
import csv
import gzip
import hashlib
import io
import json
import os
from pathlib import Path
import re
import sqlite3
import tempfile
RELEASE = "v5.2.0-export.1"
SOURCE_COMMIT = "9b255c45fc53e02a8dff547d88558d4fe91784d5"
# File sizes bound reads; hashes verify the exact bytes that the CSV parser uses.
FILES = {
"countries.csv": (108781, "64af092ba77e0b53324c29a264c50fbeb9e8d5210db99d7a98c834d68e89a1e1"),
"states.csv": (665924, "86606b87ede32f867898a343197c1374c4ed9a1692ee24dcc214739451cbbf3c"),
"cities.csv.gz": (5099371, "1721f5b70527fb712777fb1915da42795a3c0d3855f9c055276c6d9b889ec3ac"),
}
SCHEMA = """
CREATE TABLE countries (
id INTEGER PRIMARY KEY CHECK (id > 0), name TEXT NOT NULL,
iso2 TEXT NOT NULL UNIQUE CHECK (iso2 GLOB '[A-Z][A-Z]')
);
CREATE TABLE states (
id INTEGER PRIMARY KEY CHECK (id > 0), name TEXT NOT NULL,
country_id INTEGER NOT NULL REFERENCES countries(id), iso2 TEXT NOT NULL,
UNIQUE (id, country_id), UNIQUE (country_id, iso2)
);
CREATE TABLE cities (
id INTEGER PRIMARY KEY CHECK (id > 0), name TEXT NOT NULL,
country_id INTEGER NOT NULL REFERENCES countries(id), state_id INTEGER,
FOREIGN KEY (state_id, country_id) REFERENCES states(id, country_id)
);
CREATE INDEX states_country ON states(country_id);
CREATE INDEX cities_country_state ON cities(country_id, state_id);
CREATE TABLE import_info (release TEXT NOT NULL, source_commit TEXT NOT NULL);
"""
def verified_files(directory):
"""Read only the expected size and verify each source before parsing any CSV."""
result = {}
for name, (size, digest) in FILES.items():
with (directory / name).open("rb") as handle:
content = handle.read(size + 1)
if len(content) != size or hashlib.sha256(content).hexdigest() != digest:
raise ValueError(f"{name}: size or SHA-256 mismatch; use the pinned source")
result[name] = content
return result
def csv_rows(content, filename, columns, nullable=()):
"""Parse UTF-8 CSV safely, preserving names and converting only identifier fields."""
binary = io.BytesIO(content)
if filename.endswith(".gz"):
binary = gzip.GzipFile(fileobj=binary)
with io.TextIOWrapper(binary, encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle, strict=True)
fields = reader.fieldnames or []
if len(fields) != len(set(fields)) or not set(columns).issubset(fields):
raise ValueError(f"{filename}: missing or duplicate CSV headers")
for row in reader:
if None in row or any(value is None for value in row.values()):
raise ValueError(f"{filename}: malformed CSV row {reader.line_num}")
values = []
for column in columns:
value = row[column]
if column == "id" or column.endswith("_id"):
if not value and column in nullable:
values.append(None)
elif re.fullmatch(r"[0-9]+", value) and 0 < int(value) < 2**63:
values.append(int(value))
else:
raise ValueError(f"{filename}: invalid {column} at row {reader.line_num}")
elif not value.strip() or "\x00" in value:
raise ValueError(f"{filename}: empty or invalid {column} at row {reader.line_num}")
else:
values.append(value)
yield tuple(values)
def import_csv(directory, output):
"""Build privately, enforce relationships, then publish without replacing an existing file."""
directory, output = Path(directory), Path(output).absolute()
if output.exists() or output.is_symlink():
raise FileExistsError(f"Destination already exists: {output}")
sources = verified_files(directory)
with tempfile.TemporaryDirectory(prefix=".csc-import-", dir=output.parent) as work:
database = Path(work) / "locations.sqlite"
connection = sqlite3.connect(database)
try:
connection.execute("PRAGMA foreign_keys = ON")
if connection.execute("PRAGMA foreign_keys").fetchone() != (1,):
raise RuntimeError("SQLite foreign-key enforcement is unavailable")
connection.executescript(SCHEMA)
with connection:
# Parent tables first. SQL identifiers are fixed; CSV values are bound parameters.
for name, columns, statement, nullable in [
("countries.csv", ("id", "name", "iso2"), "INSERT INTO countries VALUES (?, ?, ?)", ()),
("states.csv", ("id", "name", "country_id", "iso2"), "INSERT INTO states VALUES (?, ?, ?, ?)", ()),
("cities.csv.gz", ("id", "name", "country_id", "state_id"), "INSERT INTO cities VALUES (?, ?, ?, ?)", ("state_id",)),
]:
try:
connection.executemany(statement, csv_rows(sources[name], name, columns, nullable))
except sqlite3.IntegrityError as error:
raise ValueError(f"{name}: key or relationship constraint failed: {error}") from error
connection.execute("INSERT INTO import_info VALUES (?, ?)", (RELEASE, SOURCE_COMMIT))
if connection.execute("PRAGMA foreign_key_check").fetchall():
raise ValueError("Foreign-key verification failed")
counts = {table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
for table in ("countries", "states", "cities")}
counts["cities_without_state"] = connection.execute("SELECT COUNT(*) FROM cities WHERE state_id IS NULL").fetchone()[0]
finally:
connection.close()
os.chmod(database, 0o600)
# A same-filesystem hard link publishes atomically and fails if output now exists.
os.link(database, output)
return {"release": RELEASE, **counts}
def main():
"""Run the local import; report a specific failure without publishing a partial database."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="Directory containing the three pinned CSV files")
parser.add_argument("output", type=Path, help="New SQLite file; existing files are never replaced")
args = parser.parse_args()
try:
print(json.dumps(import_csv(args.directory, args.output), indent=2))
except (OSError, ValueError, RuntimeError, csv.Error, sqlite3.Error) as error:
parser.exit(1, f"Import failed: {error}\n")
if __name__ == "__main__":
main()
4. Join locations by their IDs.
Join cities to countries by country_id. Use a left join for the optional state link when listing every city. State codes are country-scoped; city names are useful search filters but can repeat.
PRAGMA foreign_keys = ON;
-- Keep cities that have no state. This join returns one row per imported city.
SELECT city.id, city.name AS city, state.name AS state, country.name AS country
FROM cities AS city
JOIN countries AS country ON country.id = city.country_id
LEFT JOIN states AS state
ON state.id = city.state_id AND state.country_id = city.country_id
ORDER BY city.id
LIMIT 10;
-- Country + state lookup: codes are scoped by country, names are not unique keys.
SELECT city.id, city.name
FROM cities AS city
JOIN countries AS country ON country.id = city.country_id
JOIN states AS state
ON state.id = city.state_id AND state.country_id = city.country_id
WHERE country.iso2 = 'IN' AND state.iso2 = 'MH' AND city.name = 'Mumbai';
-- These rows are valid records, not broken foreign keys.
SELECT id, name, country_id FROM cities WHERE state_id IS NULL ORDER BY id;
-- No rows means every non-null relationship is valid.
PRAGMA foreign_key_check;
-- Identify the pinned snapshot represented by this database.
SELECT release, source_commit FROM import_info;
If the SQLite3 CLI is installed, download the query file into the same directory and run it read-only:
curl -fL 'https://countrystatecity.in/examples/csc-location-queries.sql' -o csc-location-queries.sql
sqlite3 -readonly -header -column locations.sqlite < csc-location-queries.sql5. Check counts and nullable links.
The exact importer above was run against all three pinned source files. It produced this result:
{
"release": "v5.2.0-export.1",
"countries": 250,
"states": 5327,
"cities": 156169,
"cities_without_state": 3
}The joined Mumbai row is 133024, Mumbai, Maharashtra, India. PRAGMA foreign_key_check returned no rows. A left join to states retained all 156,169 cities; an inner join returned 156,166, omitting the three valid null-state records.
- Keep foreign-key enforcement enabled on each SQLite connection that writes data.
- Record the release with imported IDs, and review migration notes before upgrading snapshots.
- When using a filtered export, include its required parent countries and states and verify their relationships.
- Inspect place types and administrative levels in the source when your application needs a narrower definition of a city.
Questions about importing CSV into SQL.
How do I import country, state and city CSV files into SQL?
Choose one pinned snapshot, verify the source files, then import countries, states and cities in that order. Preserve the supplied IDs and enable foreign-key enforcement. This guide provides a Python standard-library importer that creates a new local SQLite projection.
Why does a city sometimes have no state?
The tested v5.2.0-export.1 snapshot contains three cities with blank CSV state_id values. The importer converts those blanks to SQL NULL. A left join to states preserves these rows when querying the complete city dataset.
Does this import every CSV column?
No. The example retains IDs, names, country links, state links and ISO2 codes in three entity tables, plus a provenance table. Counties, coordinates, translations, population and other columns are outside this projection. Use the published engine-specific exports when you need their database schema.
Can I load a MySQL dump into PostgreSQL or SQLite?
Select the export for the target database engine. The release publishes separate MySQL, PostgreSQL and SQLite files. This guide executes and tests the CSV-to-SQLite path; SQL syntax, types and import tooling differ across engines.
Choose how you maintain the data.
A local snapshot works for offline queries, prototypes and application-specific joins. The hosted API provides managed access to the dataset currently served there, with search and other features on eligible plans. The Export Tool provides filtered downloads; compare fields, formats and pricing for your use case.