Single Bash entrypoint for generating MySQL 8 or PostgreSQL import packages from MaxMind GeoIP/GeoIP2/GeoLite2 CSV ZIP archives.
Current release config format: v1.3
Supported config set includes:
- location databases: GeoLite2 City/Country, GeoIP2 City/Country, GeoIP2 Enterprise
- network-only databases: Anonymous IP, Anonymous Plus, Residential Proxy, ISP, Connection Type, Domain, ASN, IP Risk, Static IP Score, and User Count
- Bash 5+
awkunzipcurlfor HTTP(S) downloadsmysqlorpsqlonly whensettings.auto_execute_loader_sh = true, or when running the generatedloader.sh
On Windows, run the scripts from a Unix-like shell such as MSYS2 MINGW64 or UCRT64.
For HTTP(S) sources, cached downloads in converted/_archives/ are reused when
a HEAD request returns a Content-Length equal to the cached ZIP size.
./maxmind-csv2sql-converter.sh -c configs/default.mysql.ini
./maxmind-csv2sql-converter.sh -c configs/default.postgresql.ini
./maxmind-csv2sql-converter.sh -c configs/default.mysql.ini --list-editions
./maxmind-csv2sql-converter.sh -c configs/default.postgresql.ini --list-editionsThe convert-*.sh scripts are thin wrappers for the default configs:
./convert-default.mysql.sh
./convert-default.postgresql.shConfig filenames are significant:
project_name.database_type.ini
Examples:
default.mysql.ini
default.postgresql.ini
production.mysql.ini
production.postgresql.ini
The default configs keep all supported editions in one file per database type:
configs/default.mysql.ini and configs/default.postgresql.ini. A normal run
converts every uncommented edition from [editions].edition_ids;
--list-editions prints the effective list.
Config lookup first checks an edition-scoped section such as
[GeoLite2-Country-CSV.download], then falls back to an unscoped section such as
[download]. Package-level execution, output, cleanup, archive, and client
settings live in [settings] because one config generates one output package
and one loader.sh; the database type comes from the config filename suffix,
such as .mysql.ini or .postgresql.ini. [settings] keys are read only from
that exact section, not from edition-scoped sections.
Single-edition configs can still use plain [download], [database],
[convert], and [columns.<table>] sections.
Example MySQL package settings:
[settings]
# MySQL client command; values: command name from PATH or path to executable.
mysql_command = mysql
# mysql_command = C:/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe
# MySQL host; values: IP address, DNS name, or localhost.
mysql_host = 127.0.0.1
# MySQL port; values: integer, usually 3306.
mysql_port = 3306
# MySQL user; values: user name.
mysql_user = root
# MySQL password; values: string or empty for passwordless authentication.
mysql_password = root
# Additional mysql arguments; values: CLI argument string or empty.
mysql_extra_args =
# DEFINER for MySQL routines; values: user@host in MySQL syntax or empty to omit DEFINER.
mysql_routine_definer =
# Base output directory; values: relative or absolute path.
output_dir = ../converted
# Generated result ZIP name; values: filename with or without .zip, or empty to skip archive creation.
output_archive_name =
# Execute the generated SQL after preparing files; values: true or false.
auto_execute_loader_sh = false
# Delete extracted CSV/TSV files after successful auto_execute_loader_sh=true run; values: true or false.
auto_delete_csvs = false
# Print converter debug timings; values: true or false.
on_debug = falseExample PostgreSQL package settings:
[settings]
# PostgreSQL client command; values: command name from PATH or path to executable.
postgresql_command = psql
# postgresql_command = C:/Program Files/PostgreSQL/16/bin/psql.exe
# PostgreSQL host; values: IP address, DNS name, or localhost.
postgresql_host = 127.0.0.1
# PostgreSQL port; values: integer, usually 5432.
postgresql_port = 5432
# PostgreSQL user; values: user name.
postgresql_user = postgres
# PostgreSQL database used for the connection; values: existing database name.
postgresql_database = maxmind
# PostgreSQL password; values: string or empty for passwordless/.pgpass authentication.
postgresql_password = postgres
# Additional psql arguments; values: CLI argument string or empty.
postgresql_extra_args =
# Base output directory; values: relative or absolute path.
output_dir = ../converted
# Generated result ZIP name; values: filename with or without .zip, or empty to skip archive creation.
output_archive_name =
# Execute the generated SQL after preparing files; values: true or false.
auto_execute_loader_sh = false
# Delete extracted CSV/TSV files after successful auto_execute_loader_sh=true run; values: true or false.
auto_delete_csvs = false
# Print converter debug timings; values: true or false.
on_debug = falsepostgresql_database is the existing database passed to psql --dbname.
Generated SQL creates schemas inside that database; it does not run
CREATE DATABASE or switch databases. Per-edition schema_name controls the
target MySQL database name or PostgreSQL schema name for generated tables.
For one-off debug timing without editing the config, set ON_DEBUG=true in the
environment before running the converter. Debug timing prints each measured step and
its elapsed time to stderr.
Edition-level conversion settings, including custom SQL generator selection,
belong in [<edition>.convert]:
[GeoLite2-Country-CSV.convert]
# SQL Bash generator file; values: functions.bash or relative/absolute path to a custom functions.bash.
sql_bash_generator_file = functions.bash
# IP block versions to import; values: 4, 6, or comma-separated list 4,6.
ip_versions = 4,6
# Locations CSV locales to import; values: en, ru, de, es, fr, ja, pt-BR, zh-CN, comma-separated.
locales = en,ru,de,es,fr,ja,pt-BR,zh-CN
# Blocks CSV columns used, in priority order, to derive e_geoname_id; values: geoname_id, registered_country_geoname_id, represented_country_geoname_id.
e_geoname_id_priority_columns = geoname_id,registered_country_geoname_id,represented_country_geoname_idWith the default functions.bash value, the converter first resolves
../generators/<project_name>/functions.bash relative to the config directory.
For configs under configs/, that maps to generators/<project_name>/functions.bash
in the repository root. If it is missing, the converter falls back to the bundled
generators/default/functions.bash.
Each processed MaxMind edition is validated against the root directory inside
the ZIP, for example GeoLite2-Country-CSV_Example/ or GeoLite2-City-CSV_20260605/.
Each edition has its own archive source:
[editions]
# MaxMind editions to process sequentially; values: GeoIP/GeoIP2/GeoLite2 *-CSV ids, one per line or comma-separated.
# Comment out editions that should not be converted.
edition_ids =
GeoIP2-Anonymous-IP-CSV
GeoIP-Anonymous-Plus-CSV
GeoLite2-ASN-CSV
# GeoIP2-City-CSV
GeoLite2-City-CSV
GeoIP2-Connection-Type-CSV
# GeoIP2-Country-CSV
GeoLite2-Country-CSV
GeoIP2-Domain-CSV
GeoIP2-Enterprise-CSV
GeoIP2-IP-Risk-CSV
GeoIP2-ISP-CSV
GeoIP-Residential-Proxy-CSV
GeoIP2-Static-IP-Score-CSV
GeoIP2-User-Count-CSV
[GeoLite2-Country-CSV.download]
# MaxMind edition expected by this section; values: one edition_id from [editions].
edition_id = GeoLite2-Country-CSV
# MaxMind license key for official downloads; values: a string, or empty for local archives and URLs without ${license_key}.
license_key = M8jFjv3vZWrOCaZN
# MaxMind ZIP archive source; values: HTTP(S) URL or local path; supports ${edition_id}, ${license_key}, ${database_type}, and ${project_name}.
source_archive_uri = https://download.maxmind.com/app/geoip_download?edition_id=${edition_id}&license_key=${license_key}&suffix=zip
# Mirror examples:
# source_archive_uri = https://some.maxmind.csv.repository/${edition_id}.zip
# source_archive_uri = ../test/data/GeoLite2-Country-CSV_20260605.zip
# Try a direct download before proxy attempts; values: true or false.
try_without_proxy = true
# curl connection timeout in seconds; values: integer greater than 0.
connect_timeout_seconds = 5
# Maximum time for one curl download attempt in seconds; values: integer greater than 0.
max_time_seconds = 1800
# Number of curl retry attempts; values: integer 0 or greater.
retries = 2
# Proxy list file; values: relative/absolute path or empty when proxies are not needed.
proxy_file = proxy.txt
...Mirror and local archive examples:
# Mirror examples:
# source_archive_uri = https://some.maxmind.csv.repository/${edition_id}.zip
# source_archive_uri = ../test/data/GeoLite2-Country-CSV_Example.zip
# source_archive_uri = C:\data\GeoLite2-Country-CSV_20260605.zipTemplate variables available in source_archive_uri: ${edition_id}, ${license_key}, ${database_type}, and ${project_name}.
The script extracts selected CSV files, generates raw import tables, imports CSV into those raw tables, builds staging tables, creates indexes/routines, and promotes staging tables to production names.
Output is written to:
converted/<project_name>.<database_type>/
For default.mysql.ini this is converted/default.mysql/; for
default.postgresql.ini this is converted/default.postgresql/. When multiple
editions are enabled, generated SQL files are accumulated in that one
project/database directory. Extracted CSV/TSV files are stored per archive root under
converted/<project_name>.<database_type>/csv/, for example
converted/default.mysql/csv/GeoLite2-Country-CSV_Example/, to avoid
filename collisions between editions and between MySQL/PostgreSQL runs.
Important generated files:
00_create_raw.sql,10_load_raw.sql,20_transform_stage.sql,90_indexes_stage.sql,98_promote.sql,99_routines.sql,100_cleanup_raw.sql.load_data.sql- full import script.loader.sh- runsload_data.sqlwithmysqlorpsql.
The actual include order is defined by load_data.sql. MySQL runs cleanup
before promotion and then creates routines; PostgreSQL promotes, creates
routines, and cleans raw tables inside its single transaction. See
generators/readme.md when implementing a custom SQL Bash generator.
With settings.auto_execute_loader_sh = false, the package is generated but not
run. With settings.auto_execute_loader_sh = true, loader.sh is executed
immediately.
PostgreSQL load_data.sql wraps schema creation, raw load, transform, indexing,
promote, routines, and cleanup in one BEGIN/COMMIT.
MySQL cannot honestly wrap the same whole flow because statements such as
CREATE SCHEMA, CREATE/DROP/ALTER TABLE, CREATE/DROP FUNCTION/PROCEDURE,
and RENAME TABLE perform implicit commits. Generated MySQL SQL wraps
LOAD DATA and INSERT batches in explicit transactions where possible, while
DDL and promote remain implicit-commit operations. Promote starts with one
multi-table RENAME TABLE, then normalizes generated MySQL secondary index and
foreign-key names.
Every database creates dataset_metadata plus IP block tables for the enabled
IP versions. Location databases additionally create normalized location
tables. City and Enterprise configs create timezone,
country, subdivision1, subdivision2, city, and ipv4 and/or ipv6.
Enterprise also creates isp and adds foreign keys from enabled
ipv4/ipv6.isp_id columns to isp.isp_id.
Country configs create country plus ipv4 and/or ipv6.
Foreign keys from a network to its four Location references use
ON UPDATE RESTRICT ON DELETE RESTRICT. A Location row is shared lookup data:
deleting a represented- or registered-country row must not silently delete the
whole network row through CASCADE. The normalized City hierarchy keeps
CASCADE, where deleting a parent dimension intentionally removes its child
dimension rows. Normal imports replace staged tables atomically and do not
depend on either action.
Network-only databases create only ipv4 and/or ipv6 for the enabled IP
versions. The network column is the natural primary key; generated
start_ip/last_ip columns support range lookup/indexing. In MySQL these range
columns are numeric values, while in PostgreSQL they are inet values.
PostgreSQL inet always has a prefix length; generated range boundaries use
host prefixes (/32 for IPv4 and /128 for IPv6), not the source network
prefix. These datasets do not have synthetic foreign keys because MaxMind
provides no separate stable key beyond network.
Project configs can choose output columns via [columns.<table>] or
[<edition>.columns.<table>] sections.
name = true and continent_name = true create one column per locale from
convert.locales, such as name_en and name_ru.
Some columns are required by the default generator:
- all datasets: metadata fields plus IP
networkand range columns - location datasets: primary keys,
e_geoname_id, ande_geoname_source_code; the three source GeoName ID columns are optional - Enterprise:
isp.isp_idwhen theisptable is enabled
Required columns are intentionally omitted from the bundled configs; missing
settings use the default enabled value. Optional dimension tables that would
contain only their required key are not generated. For example, setting
[GeoLite2-City-CSV.columns.timezone] name = false omits the timezone table
and the dependent city.time_zone_id column.
Raw tables are created from actual CSV headers. Newly added MaxMind columns are imported into raw tables automatically; production output must be enabled in the config and typed in the default generator.
Location-capable editions can restrict imported location rows with
[<edition>.location_filters], or plain [location_filters] in a single-edition
config. These sections apply only to City, Country, and Enterprise archives.
Network-only archives have no Locations CSV, so no location filters are read.
Filter keys must be real columns from the archive's *-Locations-en.csv.
Values are case-insensitive regular expressions. A comma-separated value creates
an OR group for one column, and multiple filter keys are combined with AND.
If a regex itself contains a comma, wrap that value in double quotes.
Country, City, and Enterprise archives can filter by common location columns
such as geoname_id, continent_code, continent_name, country_iso_code,
country_name, and is_in_european_union. City and Enterprise archives can
also filter by city-level columns such as
subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,
subdivision_2_name, city_name, metro_code, and time_zone.
Example:
[GeoLite2-City-CSV.location_filters]
# Optional Locations CSV row filters for this edition; all enabled keys are combined with AND.
# Values: case-insensitive regex or comma-separated regex list; quote values that contain commas.
# Uncomment only the filters you need.
# Filter by geoname_id; values: regex or comma-separated regex list.
# geoname_id = .*777.*
# Filter by continent_code; values: regex or comma-separated regex list.
# continent_code = EU,NA,OC
# Filter by continent_name; values: regex or comma-separated regex list.
# continent_name = Europe|Africa,Asia
# Filter by country_iso_code; values: regex or comma-separated regex list.
# country_iso_code = AU,NZ,GB,IE,US,CA,CY
# Filter by country_name; values: regex or comma-separated regex list.
# country_name = Austr.*,Zealand$,^United,Ireland,Canada|Cyprus
# Filter by is_in_european_union; values: regex, usually 0 or 1.
# is_in_european_union = 0|1
# Filter by subdivision_1_iso_code; values: regex or comma-separated regex list.
# subdivision_1_iso_code = WO|JD|NU|GE|A.*
# Filter by subdivision_1_name; values: regex or comma-separated regex list.
# subdivision_1_name = .*O.*
# Filter by subdivision_2_iso_code; values: regex or comma-separated regex list.
# subdivision_2_iso_code = .*
# Filter by subdivision_2_name; values: regex or comma-separated regex list.
# subdivision_2_name = .*A.*
# Filter by city_name; values: regex or comma-separated regex list; quote values that contain commas.
# city_name = Newport,^Clinton$|^Richmond$,"Mandria, Paphos",^Salem
# Filter by metro_code; values: regex or comma-separated regex list.
# metro_code = .*
# Filter by time_zone; values: regex or comma-separated regex list.
# time_zone = .*/.*E.*The default generator exposes the same logical API in MySQL and PostgreSQL.
MySQL table-valued entry points are procedures called with CALL; PostgreSQL
entry points are functions queried with SELECT * FROM. Scalar helpers are
functions in both databases.
Every generated namespace, including ASN and all other network-only editions, contains:
find_network_by_ip(ip)find_networks_by_json_ips(ips_json, page, per_page)get_dataset_metadata()
Network lookup always uses one stable result shape: status, ip,
ip_version, followed by the union of the configured IPv4/IPv6 output
columns. A single lookup always returns exactly one row. Status is ok for a
matching network, not_found for a valid address outside the dataset, and
invalid_ip for invalid input. CIDR input such as 192.0.2.0/24 is not
accepted as an IP address.
The JSON batch helper requires a top-level array and accepts 1-based page and
positive per_page arguments. It preserves order and duplicates; ord is the
absolute 1-based position in the original array. Every returned row also has
total_count, the number of input elements before pagination, so a client can
calculate ceil(total_count / per_page). Strings are validated strictly; JSON
null, objects, arrays, and invalid address strings produce an invalid_ip
row without aborting the rest of the page.
The stable union shape is intentional for mixed IPv4/IPv6 requests. For
example, GeoIP2 User Count returns all configured IPv4 and IPv6 count columns
from find_networks_by_json_ips; columns that do not apply to the matched IP
family are null. The physical ipv4 and ipv6 tables still contain only the
columns enabled for that family.
PostgreSQL additionally provides IP-family-specific helpers when that family is enabled:
find_ipv4_network_by_ip(ip)andfind_ipv6_network_by_ip(ip)find_ipv4_networks_by_json_ips(ips_json, page, per_page)andfind_ipv6_networks_by_json_ips(ips_json, page, per_page)
Examples:
-- MySQL
CALL maxmind_asn.find_network_by_ip('1.0.0.1');
CALL maxmind_asn.find_networks_by_json_ips(
JSON_ARRAY('1.0.0.1', '203.0.113.1', 'bad', NULL), 1, 40
);
-- PostgreSQL
SELECT * FROM maxmind_asn.find_network_by_ip('1.0.0.1');
SELECT * FROM maxmind_asn.find_networks_by_json_ips(
'["1.0.0.1", "203.0.113.1", "bad", null]'::jsonb, 1, 40
);MaxMind's geoname_id is nullable. It is not interchangeable with
registered_country_geoname_id or represented_country_geoname_id; each
column describes a different role. Location network tables always add:
e_geoname_id— the effective location selected by the configured priority;e_geoname_source_code— the exact source column used for that row.
Each source role is stored only when its columns.ipv4/columns.ipv6 setting
is enabled. Effective values are still calculated from the raw Blocks CSV, so
all three source columns may be disabled without losing e_geoname_id.
The source codes have fixed meanings independent of configured priority:
| Code | Source column |
|---|---|
1 |
geoname_id |
2 |
registered_country_geoname_id |
3 |
represented_country_geoname_id |
The converter stores the value and source together. The CHECK constraint
enforces their nullability and valid source codes; when a source role is also
stored, it additionally enforces equality with e_geoname_id. It does not
infer the source by comparing values, because two source columns can contain
the same GeoName ID.
When no configured source resolves to a loaded Location row, both effective
columns are null and the network row is still retained. Generic network lookup
has status = 'ok' because the network was found; the ID-focused location API
uses status = 'no_location'. Enabled original IDs are returned as stored,
and network flags and other edition-specific data remain available. For
example, MaxMind's all-null-ID example rows carry anonymous-proxy, satellite,
or anycast flags and must not be confused with a network absent from the data.
Selection is intentionally two-pass. In configured priority order, the
converter first chooses a source that resolves to a loaded Location row with a
country ISO code; if none does, it chooses the first source that resolves to
any loaded Location row. This preserves useful country-level output when a
more specific source cannot supply it. schema_version identifies this
algorithm, while metadata preserves the configured source order.
Location schemas provide these ID-focused helpers:
find_geoname_id_by_ip(ip)returns the original MaxMindgeoname_idonly and is generated only when that column is enabled for at least one selected IP family;find_e_geoname_id_by_ip(ip)returnse_geoname_id;find_geoname_ids_by_ip(ip)returns the status, network, effective ID, readable effective source, and the enabled original IDs;find_geoname_ids_by_json_ips(ips_json, page, per_page)is the safe paged batch form and includestotal_count.
The two find_geoname_ids... entry points use ok, no_location,
not_found, and invalid_ip. no_location means that the IP matched a stored
network but no configured source resolved to a loaded Location row. Their
public result uses e_geoname_id and e_geoname_source; a
column named geoname_id in these ID-focused results means the original
MaxMind field. Result signatures contain the union of roles enabled for IPv4
and IPv6. A role that is enabled only for the other family is returned as
NULL.
-- MySQL
CALL maxmind_geoip2_city.find_geoname_ids_by_ip('81.2.69.142');
CALL maxmind_geoip2_city.find_geoname_ids_by_json_ips(
JSON_ARRAY('81.2.69.142', 'bad', NULL, '2001:218::'), 1, 40
);
-- PostgreSQL
SELECT *
FROM maxmind_geoip2_city.find_geoname_ids_by_ip('81.2.69.142');
SELECT *
FROM maxmind_geoip2_city.find_geoname_ids_by_json_ips(
'["81.2.69.142", "bad", null, "2001:218::"]'::jsonb, 1, 40
);City, Country, and Enterprise schemas also generate:
find_e_geoname_ids_by_json_ips(ips_json, page, per_page)find_geoinfo_by_json_ips(ips_json, include_continent, include_iso_code, include_emoji, page, per_page)find_geoinfo_by_json_geoname_ids(geoname_ids_json, include_continent, include_iso_code, include_emoji, page, per_page)build_location_label_by_geoname_id(geoname_id, locale, include_continent, include_iso_code, include_emoji)build_location_labels_by_ip(ip, locales_json, include_continent, include_iso_code, include_emoji)build_location_labels_by_json_geoname_ids(geoname_ids_json, locale, include_continent, include_iso_code, include_emoji, page, per_page)
The three include arguments use 0 or 1.
The two find_geoinfo... routines return country_iso_code when that source
column is enabled and one location_<locale> column per configured locale.
Their include arguments apply to every location_<locale> value. They do not
return a separate location_label; use a build_location_* helper when one
label for a selected locale is needed.
The public ID columns of these batch routines are:
| Routine | ID columns | Meaning |
|---|---|---|
find_e_geoname_ids_by_json_ips |
ip, e_geoname_id |
Effective ID selected from the configured source priority |
find_geoinfo_by_json_ips |
ip, geoname_id |
Location lookup key resolved from the matching network's e_geoname_id |
find_geoinfo_by_json_geoname_ids |
geoname_id |
Location lookup key supplied in the input JSON array |
Thus, geoname_id in a geoinfo result is the Location lookup key used to build
country_iso_code and location_<locale> values. If no Location row matches,
the key is retained and the derived values are null. Use
find_e_geoname_ids_by_json_ips when the public result must explicitly retain
the effective-ID name e_geoname_id.
build_location_label_by_geoname_id prefers the requested configured locale,
then English when available, then the remaining configured locales. Fallback
is applied independently to each hierarchy component, so a missing Russian
city name can fall back to English without changing an available Russian
country or subdivision name. Duplicate country/subdivision/city names are
suppressed. Locale values are normalized (_ to -, case-insensitive), and
both ru and an accidentally JSON-encoded "ru" are accepted.
build_location_labels_by_ip performs one network lookup, then returns one row
per requested locale. It always returns the effective ID/source and
e_geoname_label. Every enabled original role is followed by its own resolved
label: geoname_label, registered_country_label, or
represented_country_label. Disabled roles are omitted from the result. This
is a normal many-to-one lookup: any number of network rows may refer to the
same Location row. A missing role remains null and does not hide the others.
All paged JSON routines use page/per_page, absolute ord, and
total_count. The batch label builder also returns parsed geoname_id,
status, and location_label. Status is ok, not_found, or
invalid_geoname_id; one bad JSON element does not abort the page.
-- MySQL
CALL maxmind_geoip2_city.find_geoinfo_by_json_ips(
JSON_ARRAY('81.2.69.142', '2001:218::'), 1, 1, 1, 1, 40
);
SELECT maxmind_geoip2_city.build_location_label_by_geoname_id(2643743, 'ru', 1, 1, 1);
CALL maxmind_geoip2_city.build_location_labels_by_ip(
'81.2.69.142', JSON_ARRAY('ru', 'en'), 1, 1, 1
);
CALL maxmind_geoip2_city.build_location_labels_by_json_geoname_ids(
JSON_ARRAY(2643743, NULL, 'bad'), 'ru', 1, 1, 1, 1, 40
);
-- PostgreSQL
SELECT * FROM maxmind_geoip2_city.find_geoinfo_by_json_ips(
'["81.2.69.142", "2001:218::"]'::jsonb, 1, 1, 1, 1, 40
);
SELECT maxmind_geoip2_city.build_location_label_by_geoname_id(2643743, 'ru', 1, 1, 1);
SELECT * FROM maxmind_geoip2_city.build_location_labels_by_ip(
'81.2.69.142', '["ru", "en"]'::jsonb, 1, 1, 1
);
SELECT *
FROM maxmind_geoip2_city.build_location_labels_by_json_geoname_ids(
'[2643743, null, "bad"]'::jsonb, 'ru', 1, 1, 1, 1, 40
);Every generated namespace contains one row in dataset_metadata. The same
row is returned as native columns by get_dataset_metadata(). It records:
run_id,edition_id, and logicalarchive_root;source_build_dateand database-sideimported_at;converter_versionandschema_version;dbms,project_name, andschema_name;- JSON/JSONB arrays for
ip_versions,selected_locales,loaded_locales, ande_geoname_id_priority_columns.
The metadata never stores the source URL, filesystem path, credentials,
license key, or command line. Example archives have a null
source_build_date. Network-only datasets use empty locale and GeoName
priority arrays. Editions processed by one config invocation share a
run_id.
-- MySQL
CALL maxmind_asn.get_dataset_metadata();
-- PostgreSQL
SELECT * FROM maxmind_asn.get_dataset_metadata();The examples in this section were captured on 2026-08-20 from converter 1.3
output loaded into MySQL 8 and PostgreSQL 16. The test build used IPv4 and IPv6,
all eight supported locales, and these inputs:
GeoLite2-City-CSV_20260818.zip,GeoLite2-Country-CSV_20260818.zip, andGeoLite2-ASN-CSV_20260818.zipfor real data;- the bundled Example archives for Anonymous IP, Anonymous Plus, Connection Type, Domain, IP Risk, ISP, Residential Proxy, Static IP Score, and User Count.
The post-import smoke run discovered 68 MySQL routines and executed 100 calls, then discovered 116 PostgreSQL routines and executed 136 calls. Both runs finished with zero failed calls; the additional calls cover paging, invalid inputs, both IP families, and alternate locale/flag combinations.
The machines, caches, and archive versions affect timings, so these numbers are
examples rather than benchmarks. MySQL client times are rounded to hundredths
of a second; the table scan checklist below also gives exact SHOW PROFILES
server durations. PostgreSQL times come from psql with \timing on.
The equivalent single-IP ASN lookups are:
-- MySQL
CALL maxmind_asn.find_network_by_ip('1.0.0.1');
-- PostgreSQL
SELECT * FROM maxmind_asn.find_network_by_ip('1.0.0.1');Both return:
+--------+---------+------------+------------+--------------------------+--------------------------------+
| status | ip | ip_version | network | autonomous_system_number | autonomous_system_organization |
+--------+---------+------------+------------+--------------------------+--------------------------------+
| ok | 1.0.0.1 | 4 | 1.0.0.0/24 | 13335 | Cloudflare, Inc. |
+--------+---------+------------+------------+--------------------------+--------------------------------+
Pagination is applied to input elements, preserves their absolute ord, and
keeps total_count on every returned row. This asks for page 2 with two items
per page:
-- MySQL
CALL maxmind_asn.find_networks_by_json_ips(
JSON_ARRAY('1.0.0.1', '8.8.8.8', 'bad', NULL), 2, 2
);
-- PostgreSQL
SELECT * FROM maxmind_asn.find_networks_by_json_ips(
'["1.0.0.1", "8.8.8.8", "bad", null]'::jsonb, 2, 2
);+-----+-------------+------+------------+------------+---------+--------------------------+--------------------------------+
| ord | total_count | ip | status | ip_version | network | autonomous_system_number | autonomous_system_organization |
+-----+-------------+------+------------+------------+---------+--------------------------+--------------------------------+
| 3 | 4 | bad | invalid_ip | NULL | NULL | NULL | NULL |
| 4 | 4 | NULL | invalid_ip | NULL | NULL | NULL | NULL |
+-----+-------------+------+------------+------------+---------+--------------------------+--------------------------------+
Cloudflare for 1.0.0.1 and Google for 8.8.8.8, with the same
total_count = 4.
The three original GeoName roles remain distinct from the effective value:
-- MySQL
CALL maxmind_geolite2_city.find_geoname_ids_by_ip('81.2.69.142');
-- PostgreSQL
SELECT *
FROM maxmind_geolite2_city.find_geoname_ids_by_ip('81.2.69.142');+--------+-------------+------------+----------------+--------------+------------------+------------+-------------------------------+--------------------------------+
| status | ip | ip_version | network | e_geoname_id | e_geoname_source | geoname_id | registered_country_geoname_id | represented_country_geoname_id |
+--------+-------------+------------+----------------+--------------+------------------+------------+-------------------------------+--------------------------------+
| ok | 81.2.69.142 | 4 | 81.2.69.128/25 | 2651985 | geoname_id | 2651985 | 2635167 | NULL |
+--------+-------------+------------+----------------+--------------+------------------+------------+-------------------------------+--------------------------------+
Locale fallback is performed per label component. Wiltshire has no Russian
value in this build, while the continent, country, and city do:
-- MySQL
CALL maxmind_geolite2_city.build_location_labels_by_ip(
'81.2.69.142', JSON_ARRAY('ru', 'en'), 1, 1, 1
);
-- PostgreSQL
SELECT * FROM maxmind_geolite2_city.build_location_labels_by_ip(
'81.2.69.142', '["ru", "en"]'::jsonb, 1, 1, 1
);The relevant result columns are:
| ord | locale | status | e_geoname_id | e_geoname_source | e_geoname_label |
|---|---|---|---|---|---|
| 1 | ru |
ok |
2651985 | geoname_id |
Европа - 🇬🇧 [GB] Британия - Англия - Wiltshire - Криклейд |
| 2 | en |
ok |
2651985 | geoname_id |
Europe - 🇬🇧 [GB] United Kingdom - England - Wiltshire - Cricklade |
The full result also contains geoname_label, registered_country_label, and represented_country_label beside their IDs.
The scalar helper uses the same fallback rules:
-- MySQL
SELECT maxmind_geolite2_city.build_location_label_by_geoname_id(
2643743, 'ru', 1, 1, 1
) AS location_label;
-- PostgreSQL
SELECT maxmind_geolite2_city.build_location_label_by_geoname_id(
2643743, 'ru', 1, 1, 1
) AS location_label;Европа - 🇬🇧 [GB] Британия - Англия - Лондон
Mixed-family batch lookup returns one stable User Count shape. Inapplicable family columns are null rather than being removed from individual rows:
-- MySQL
CALL maxmind_user_count.find_networks_by_json_ips(
JSON_ARRAY('1.0.0.1', '::1:0:0:1'), 1, 40
);
-- PostgreSQL
SELECT * FROM maxmind_user_count.find_networks_by_json_ips(
'["1.0.0.1", "::1:0:0:1"]'::jsonb, 1, 40
);| ord | total_count | ip | status | ip_version | network | ipv4_24 | ipv4_32 | ipv6_32 | ipv6_48 | ipv6_64 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 1.0.0.1 |
ok |
4 | 1.0.0.0/15 |
0 | 0 | NULL | NULL | NULL |
| 2 | 2 | ::1:0:0:1 |
ok |
6 | ::1:0:0:0/80 |
NULL | NULL | 0 | 0 | 0 |
Metadata is available through either the table or the generated API:
-- MySQL
CALL maxmind_geolite2_city.get_dataset_metadata();
-- PostgreSQL
SELECT * FROM maxmind_geolite2_city.get_dataset_metadata();The captured rows have the following shared logical fields and expected run/DBMS-specific differences:
| Field | MySQL | PostgreSQL |
|---|---|---|
run_id |
b20260820T171800Z_715 |
b20260820T172154Z_282 |
edition_id |
GeoLite2-City-CSV |
GeoLite2-City-CSV |
archive_root |
GeoLite2-City-CSV_20260818 |
GeoLite2-City-CSV_20260818 |
source_build_date |
2026-08-18 |
2026-08-18 |
converter_version |
1.3 |
1.3 |
schema_version |
4 |
4 |
dbms |
mysql |
postgresql |
project_name |
docs |
docs |
schema_name |
maxmind_geolite2_city |
maxmind_geolite2_city |
ip_versions |
[4, 6] |
[4, 6] |
selected_locales |
["de", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"] |
same |
loaded_locales |
["de", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"] |
same |
e_geoname_id_priority_columns |
["geoname_id", "registered_country_geoname_id", "represented_country_geoname_id"] |
same |
This build produced 42 physical tables. Every generated namespace has its own
one-row dataset_metadata; it is not a shared global table.
| Namespace | Tables |
|---|---|
maxmind_anonymous_ip |
dataset_metadata, ipv4, ipv6 |
maxmind_anonymous_plus |
dataset_metadata, ipv4, ipv6 |
maxmind_asn |
dataset_metadata, ipv4, ipv6 |
maxmind_connection_type |
dataset_metadata, ipv4, ipv6 |
maxmind_domain |
dataset_metadata, ipv4, ipv6 |
maxmind_geolite2_city |
city, country, dataset_metadata, ipv4, ipv6, subdivision1, subdivision2, timezone |
maxmind_geolite2_country |
country, dataset_metadata, ipv4, ipv6 |
maxmind_ip_risk |
dataset_metadata, ipv4, ipv6 |
maxmind_isp |
dataset_metadata, ipv4, ipv6 |
maxmind_residential_proxy |
dataset_metadata, ipv4, ipv6 |
maxmind_static_ip_score |
dataset_metadata, ipv4, ipv6 |
maxmind_user_count |
dataset_metadata, ipv4, ipv6 |
The following checklist is valid unchanged in both MySQL and PostgreSQL and
touches every physical table. The ORDER BY makes sample rows reproducible.
-- GeoIP2 Anonymous IP
SELECT * FROM maxmind_anonymous_ip.dataset_metadata;
SELECT * FROM maxmind_anonymous_ip.ipv4;
SELECT * FROM maxmind_anonymous_ip.ipv6;
-- GeoIP Anonymous Plus
SELECT * FROM maxmind_anonymous_plus.dataset_metadata;
SELECT * FROM maxmind_anonymous_plus.ipv4;
SELECT * FROM maxmind_anonymous_plus.ipv6;
-- GeoLite2 ASN
SELECT * FROM maxmind_asn.dataset_metadata;
SELECT * FROM maxmind_asn.ipv4;
SELECT * FROM maxmind_asn.ipv6;
-- GeoIP2 Connection Type
SELECT * FROM maxmind_connection_type.dataset_metadata;
SELECT * FROM maxmind_connection_type.ipv4;
SELECT * FROM maxmind_connection_type.ipv6;
-- GeoIP2 Domain
SELECT * FROM maxmind_domain.dataset_metadata;
SELECT * FROM maxmind_domain.ipv4;
SELECT * FROM maxmind_domain.ipv6;
-- GeoLite2 City
SELECT * FROM maxmind_geolite2_city.city;
SELECT * FROM maxmind_geolite2_city.country;
SELECT * FROM maxmind_geolite2_city.dataset_metadata;
SELECT * FROM maxmind_geolite2_city.ipv4;
SELECT * FROM maxmind_geolite2_city.ipv6;
SELECT * FROM maxmind_geolite2_city.subdivision1;
SELECT * FROM maxmind_geolite2_city.subdivision2;
SELECT * FROM maxmind_geolite2_city.timezone;
-- GeoLite2 Country
SELECT * FROM maxmind_geolite2_country.country;
SELECT * FROM maxmind_geolite2_country.dataset_metadata;
SELECT * FROM maxmind_geolite2_country.ipv4;
SELECT * FROM maxmind_geolite2_country.ipv6;
-- GeoIP2 IP Risk
SELECT * FROM maxmind_ip_risk.dataset_metadata;
SELECT * FROM maxmind_ip_risk.ipv4;
SELECT * FROM maxmind_ip_risk.ipv6;
-- GeoIP2 ISP
SELECT * FROM maxmind_isp.dataset_metadata;
SELECT * FROM maxmind_isp.ipv4;
SELECT * FROM maxmind_isp.ipv6;
-- GeoIP Residential Proxy
SELECT * FROM maxmind_residential_proxy.dataset_metadata;
SELECT * FROM maxmind_residential_proxy.ipv4;
SELECT * FROM maxmind_residential_proxy.ipv6;
-- GeoIP2 Static IP Score
SELECT * FROM maxmind_static_ip_score.dataset_metadata;
SELECT * FROM maxmind_static_ip_score.ipv4;
SELECT * FROM maxmind_static_ip_score.ipv6;
-- GeoIP2 User Count
SELECT * FROM maxmind_user_count.dataset_metadata;
SELECT * FROM maxmind_user_count.ipv4;
SELECT * FROM maxmind_user_count.ipv6;All 42 statements completed successfully. The first result from every distinct
network table is summarized below. start_ip and last_ip are omitted from the
compact summary: MySQL renders them as integers, while PostgreSQL renders them
as address boundaries, as shown in the full City example that follows.
Each of the 12 dataset_metadata statements returned exactly one row with the
14-column shape shown in the metadata example above and its own edition/schema
values.
| Table | First result: network; edition-specific values |
|---|---|
maxmind_anonymous_ip.ipv4 |
1.2.0.0/16; anonymous = 1, anonymous VPN = 1, other flags = NULL |
maxmind_anonymous_ip.ipv6 |
abcd:1000::/112; anonymous = 1, public proxy = 1, other flags = NULL |
maxmind_anonymous_plus.ipv4 |
1.2.0.0/32; anonymous = 1, anonymous VPN = 1, provider/confidence/last-seen = NULL |
maxmind_anonymous_plus.ipv6 |
2001:480:3a::/64; anonymous = 1, public proxy = 1, provider/confidence/last-seen = NULL |
maxmind_asn.ipv4 |
1.0.0.0/24; ASN 13335, Cloudflare, Inc. |
maxmind_asn.ipv6 |
2001:200::/37; ASN 2500, WIDE Project |
maxmind_connection_type.ipv4 |
1.0.0.0/24; Cable/DSL |
maxmind_connection_type.ipv6 |
2003::/24; Cable/DSL |
maxmind_domain.ipv4 |
1.2.0.0/16; maxmind.com |
maxmind_domain.ipv6 |
2a02:2770:3::/64; sgotti.org |
maxmind_geolite2_city.ipv4 |
1.0.0.0/24; effective ID 2077456 from registered-country ID |
maxmind_geolite2_city.ipv6 |
2001:200::/32; effective ID 1861060, latitude 35.6900, longitude 139.6900 |
maxmind_geolite2_country.ipv4 |
1.0.0.0/24; effective ID 2077456 from registered-country ID |
maxmind_geolite2_country.ipv6 |
2001:200::/32; effective/original/registered ID 1861060 |
maxmind_ip_risk.ipv4 |
214.2.3.0/30; risk 25.00, anonymous = 1, anonymous VPN = 1 |
maxmind_ip_risk.ipv6 |
no rows in GeoIP2-IP-Risk-CSV_Example.zip |
maxmind_isp.ipv4 |
1.0.128.0/17; ISP and organization TOT Public Company Limited |
maxmind_isp.ipv6 |
2001:1700::/27; ASN 6730, Sunrise Communications AG |
maxmind_residential_proxy.ipv4 |
6.0.42.17/32; novada, confidence 95, last seen 2026-05-14 |
maxmind_residential_proxy.ipv6 |
2001:480:1b20:fb4f::/64; lumiproxy, confidence 65, last seen 2026-04-09 |
maxmind_static_ip_score.ipv4 |
1.0.0.0/15; score 0.01 |
maxmind_static_ip_score.ipv6 |
::1:0:0:0/80; score 1.48 |
maxmind_user_count.ipv4 |
1.0.0.0/15; ipv4_24 = 0, ipv4_32 = 0 |
maxmind_user_count.ipv6 |
::1:0:0:0/80; ipv6_32 = 0, ipv6_48 = 0, ipv6_64 = 0 |
The normalized City dimension tables returned these first rows:
| Table | First result |
|---|---|
maxmind_geolite2_city.timezone |
id = 1, Africa/Abidjan |
maxmind_geolite2_city.country |
id = 1, continent Asia in all configured locales, no country ISO/name |
maxmind_geolite2_city.subdivision1 |
id = 1, ISO 02, Canillo / Канильо |
maxmind_geolite2_city.subdivision2 |
id = 1, ISO BAB, Babek Rayon; other locale names NULL |
maxmind_geolite2_city.city |
GeoName 11797, country 110, subdivision1 1115, Afrā, timezone 263 |
This is the complete first City IPv4 row in MySQL:
mysql> SELECT * FROM maxmind_geolite2_city.ipv4;
+------------+----------+----------+--------------+-----------------------+------------+-------------------------------+--------------------------------+--------------------+-----------------------+-------------+----------+-----------+-----------------+------------+
| network | start_ip | last_ip | e_geoname_id | e_geoname_source_code | geoname_id | registered_country_geoname_id | represented_country_geoname_id | is_anonymous_proxy | is_satellite_provider | postal_code | latitude | longitude | accuracy_radius | is_anycast |
+------------+----------+----------+--------------+-----------------------+------------+-------------------------------+--------------------------------+--------------------+-----------------------+-------------+----------+-----------+-----------------+------------+
| 1.0.0.0/24 | 16777216 | 16777471 | 2077456 | 2 | NULL | 2077456 | NULL | 0 | 0 | NULL | NULL | NULL | NULL | NULL |
+------------+----------+----------+--------------+-----------------------+------------+-------------------------------+--------------------------------+--------------------+-----------------------+-------------+----------+-----------+-----------------+------------+
PostgreSQL returns the same logical row, with native inet boundaries and
booleans:
maxmind=> SELECT * FROM maxmind_geolite2_city.ipv4;
network | start_ip | last_ip | e_geoname_id | e_geoname_source_code | geoname_id | registered_country_geoname_id | represented_country_geoname_id | is_anonymous_proxy | is_satellite_provider | postal_code | latitude | longitude | accuracy_radius | is_anycast
------------+----------+-----------+--------------+-----------------------+------------+-------------------------------+--------------------------------+--------------------+-----------------------+-------------+----------+-----------+-----------------+-----------
1.0.0.0/24 | 1.0.0.0 | 1.0.0.255 | 2077456 | 2 | | 2077456 | | f | f | | | | |
(1 row)
The real Country archive provides all configured locale columns:
SELECT *
FROM maxmind_geolite2_country.country
ORDER BY geoname_id
LIMIT 5;| geoname_id | continent | iso | emoji | name_en | name_ru | name_de | name_es | name_fr | name_ja | name_pt-BR | name_zh-CN | EU |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 49518 | AF / Africa / Африка | RW | 🇷🇼 | Rwanda | Руанда | Ruanda | Ruanda | Rwanda | ルワンダ共和国 | Ruanda | 卢旺达 | 0 / f |
| 51537 | AF / Africa / Африка | SO | 🇸🇴 | Somalia | Сомали | Somalia | Somalia | Somalie | ソマリア | Somália | 索马里 | 0 / f |
| 69543 | AS / Asia / Азия | YE | 🇾🇪 | Yemen | Йемен | Jemen | Yemen | Yémen | イエメン共和国 | Iêmen | 也门 | 0 / f |
| 99237 | AS / Asia / Азия | IQ | 🇮🇶 | Iraq | Ирак | Irak | Irak | Irak | イラク共和国 | Iraque | 伊拉克 | 0 / f |
| 102358 | AS / Asia / Азия | SA | 🇸🇦 | Saudi Arabia | Саудовская Аравия | Saudi-Arabien | Arabia Saudí | Arabie saoudite | サウジアラビア王国 | Arábia Saudita | 沙特阿拉伯 | 0 / f |
These are queries over the generated tables for analyses that are intentionally not part of the lookup API.
Find networks whose effective location came from
registered_country_geoname_id rather than geoname_id:
SELECT network,
geoname_id,
registered_country_geoname_id,
represented_country_geoname_id,
e_geoname_id,
e_geoname_source_code
FROM maxmind_geolite2_city.ipv4
WHERE e_geoname_source_code = 2
ORDER BY start_ip
LIMIT 5;| network | geoname_id | registered_country_geoname_id | represented_country_geoname_id | e_geoname_id | source code |
|---|---|---|---|---|---|
1.0.0.0/24 |
NULL | 2077456 | NULL | 2077456 | 2 |
1.1.1.0/24 |
NULL | 2077456 | NULL | 2077456 | 2 |
2.58.104.0/24 |
NULL | 798544 | NULL | 798544 | 2 |
2.58.107.0/24 |
NULL | 798544 | NULL | 798544 | 2 |
5.200.5.0/24 |
NULL | 2750405 | NULL | 2750405 | 2 |
Resolve all three country roles independently. Repeated GeoName IDs across many networks are expected: a Location row is shared lookup data, not an IP owner.
SELECT n.network,
location.iso_code AS location_iso,
registered.iso_code AS registered_iso,
represented.iso_code AS represented_iso
FROM maxmind_geolite2_country.ipv4 AS n
LEFT JOIN maxmind_geolite2_country.country AS location
ON location.geoname_id = n.geoname_id
LEFT JOIN maxmind_geolite2_country.country AS registered
ON registered.geoname_id = n.registered_country_geoname_id
LEFT JOIN maxmind_geolite2_country.country AS represented
ON represented.geoname_id = n.represented_country_geoname_id
WHERE n.geoname_id <> n.registered_country_geoname_id
ORDER BY n.start_ip
LIMIT 5;| network | location_iso | registered_iso | represented_iso |
|---|---|---|---|
1.32.194.0/24 |
TW |
SG |
NULL |
1.32.197.0/24 |
TW |
SG |
NULL |
1.32.202.0/25 |
TW |
SG |
NULL |
1.32.203.0/25 |
TW |
SG |
NULL |
1.32.204.0/25 |
TW |
SG |
NULL |
Find ASNs represented by the largest number of IPv4 CIDR rows in the current archive. This measures rows, not address-space size:
SELECT autonomous_system_number,
autonomous_system_organization,
COUNT(*) AS ipv4_networks
FROM maxmind_asn.ipv4
GROUP BY autonomous_system_number, autonomous_system_organization
ORDER BY ipv4_networks DESC
LIMIT 5;| ASN | Organization | IPv4 networks |
|---|---|---|
| 7922 | Comcast Cable Communications, LLC | 24897 |
| 7018 | AT&T Enterprises, LLC | 20326 |
| 701 | Verizon Business | 14436 |
| 4134 | Chinanet | 9389 |
| 5650 | Frontier Communications of America, Inc. | 9339 |
Audit effective-location coverage. The boolean aggregate syntax differs between the databases:
-- MySQL
SELECT COUNT(*) AS networks,
SUM(e_geoname_id IS NULL) AS without_e_geoname_id,
ROUND(100.0 * SUM(e_geoname_id IS NULL) / COUNT(*), 2)
AS without_e_geoname_pct
FROM maxmind_geolite2_city.ipv4;
-- PostgreSQL
SELECT COUNT(*) AS networks,
COUNT(*) FILTER (WHERE e_geoname_id IS NULL)
AS without_e_geoname_id,
ROUND(
100.0 * COUNT(*) FILTER (WHERE e_geoname_id IS NULL) / COUNT(*),
2
) AS without_e_geoname_pct
FROM maxmind_geolite2_city.ipv4;+----------+----------------------+-----------------------+
| networks | without_e_geoname_id | without_e_geoname_pct |
+----------+----------------------+-----------------------+
| 3704456 | 0 | 0.00 |
+----------+----------------------+-----------------------+
Find Example User Count networks with the largest combined IPv4 estimates:
SELECT network, ipv4_24, ipv4_32
FROM maxmind_user_count.ipv4
ORDER BY (ipv4_24 + ipv4_32) DESC, start_ip
LIMIT 5;| network | ipv4_24 | ipv4_32 |
|---|---|---|
1.2.3.4/32 |
4 | 3 |
214.2.3.4/32 |
4 | 3 |
1.2.3.5/32 |
4 | 1 |
1.2.3.6/32 |
4 | 1 |
214.2.3.5/32 |
4 | 1 |
Example ZIP archives live in test/data/. The test configs use paths relative
to the test/ directory, such as data/GeoLite2-Country-CSV_Example.zip.
test/count-data.sh counts rows directly from the source ZIP archives. It is
useful for checking generated database row counts after an import:
./test/count-data.sh
./test/count-data.sh GeoLite2-Country-CSV_Example.zipAfter loading the test SQL into MySQL or PostgreSQL, run
test/select-counts.sql against the database and compare the results with
count-data.sh. The SQL covers all schemas used by the current test configs,
including network-only schemas, City/Country, and Enterprise. Its second result
set checks that every dataset_metadata table contains exactly one row.
Run test/api-smoke.mysql.sql or test/api-smoke.postgresql.sql to exercise
strict IP validation, ordered mixed batches, metadata safety, effective-source
integrity, configurable page boundaries and total_count, no_location,
per-component locale fallback, three-role location labels, and
synthetic registered/represented-country fallbacks. Synthetic rows are
inserted inside a transaction and rolled back.