Building Custom Gazetteers#
The pre-configured gazetteers cover the modern world and one country in detail, but no fixed set of gazetteers can cover every research question. If you work on a region, a period, or a domain that is not represented — a national placename register, an excavation catalogue, a historical map index, your own field data — you can turn that data into a gazetteer of your own, and the library then treats it exactly like a pre-configured one.
You do this by writing a YAML configuration file that describes your source files and how their rows map onto places. There is no plugin to write and no code to run: you declare where the files are, what columns they have, and which of those columns are the identifier, the names, the geometry, and the attributes of a place. The build pipeline does the rest.
The rest of this guide walks through that process end to end on a real dataset, then documents every configuration key in full.
What You Are Building#
Before writing any configuration, it helps to know exactly what the build produces, because that is what your configuration has to describe.
The Canonical Feature Model#
Every gazetteer, however heterogeneous its sources, is projected into a single model. A gazetteer is a set of features, and each feature has exactly five things:
Field |
Meaning |
|---|---|
|
A string that identifies the place within this gazetteer and never changes. It is what gets stored in annotations, so it must be stable across rebuilds and unique across the whole gazetteer. |
|
Every string the place should be findable by: its main name, historical spellings, transliterations, names in other languages, abbreviations. Names are not ranked or labelled — they are a set of search keys. |
|
One geometry (point, line, polygon, or a multi-part combination), in the gazetteer’s coordinate reference system. It may be absent: a place that is known by name but not located is still a perfectly valid feature. |
|
A free-form dictionary of attributes: type, hierarchy, population, dates, links, descriptions — whatever your source offers and your work needs. There is no fixed schema, and different sources within one gazetteer may store entirely different keys. |
|
Which of the configuration’s sources the feature came from. Set automatically; useful for telling apart features of different kinds in one gazetteer. |
The finished gazetteer is a single self-contained SQLite file (an artifact) holding those features plus the full-text and phonetic indexes used for searching. Nothing else is installed, and the artifact is never modified after the build.
Writing a configuration is therefore an exercise in answering five questions about your data: what is one place, what identifies it, what is it called, where is it, and what else do I want to know about it.
How the Build Runs#
Understanding the three stages the build reports makes its error messages much easier to place:
Preparing sources. Each source file is downloaded or located on disk, extracted if it is a ZIP archive, and loaded into a table in a temporary analytical database (DuckDB). Errors here are about files and columns: a missing file, a column count that does not match, a value that will not convert to its declared type.
Compiling features. For each
featuresblock, the declared identifier, names, geometry, and data are compiled into SQL over the block’s source and its joins, and run. Errors here are about your expressions: an unknown column name, an invalid join clause, an identifier that collides with another block’s.Building artifact. The projected rows are written to a temporary SQLite file, indexed, verified, compacted, and only then moved into place. A failed build leaves any previously installed artifact untouched.
Source files and staging tables are discarded afterwards. Because the sources are re-read from scratch on every build, iterating on a configuration is safe: run it again and the previous artifact is replaced atomically.
Worked Example: A Gazetteer of the Ancient World#
The rest of this section builds a working gazetteer of the ancient world from scratch, one concern at a time. Every intermediate step is a valid configuration that installs and can be queried, so you can follow along and check your results as you go.
We will use two datasets that between them exercise nearly everything a configuration can do:
Pleiades, a community-built gazetteer of the ancient Mediterranean world, published as a ZIP archive of CSV exports from a relational database. It gives us places, their names in several scripts, and a controlled vocabulary of place types — spread over separate files that have to be joined back together.
A map of Roman provinces at the empire’s greatest extent, published as a single GeoJSON file in Web Mercator. It gives us polygons to locate the places in, in a different coordinate system from everything else.
The finished file is pleiades.yaml, reproduced in full at the end of the walkthrough, so you can compare your version against it at any point. It is an example rather than a pre-configured gazetteer: you install it from the file, the same way you would install a configuration of your own.
Note
Every step below uses name: pleiades, so each build replaces the previous step’s artifact — which is what you want while iterating.
Every configuration key is spelled out in these examples, including the ones that have a default, with the default noted in a comment. Real configurations usually leave those lines out; they are written here so that nothing about the file is implicit.
Step 1: Read the Data First#
Do not start with the YAML file. Start by downloading the data and looking at it, because every decision in the configuration follows from what is actually in the files.
curl -O https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
unzip -l pleiades_gis_data.zip
The archive is about 35 MB and expands to roughly 130 MB under data/gis/: seventeen CSV exports plus a README describing them. Four of those exports are relevant to us, and a fifth file comes from the second dataset:
File |
Rows |
What it contributes |
|---|---|---|
|
42,242 |
One row per place: title, description, a representative coordinate pair, a bounding box, and the Pleiades id |
|
43,708 |
One row per name, keyed to a place: the attested form in its original script plus up to three romanizations |
|
52,511 |
Which place-type keys apply to which place — more rows than places, because a place can have several types |
|
233 |
The place-type vocabulary: key, human-readable term, definition |
|
44 |
(Separate download) One MultiPolygon per Roman province |
Look at the actual bytes of each file you intend to use, not just its documentation:
head -2 data/gis/places.csv
created,description,details,provenance,title,uri,id,representative_latitude,representative_longitude,bounding_box_wkt,location_precision
2021-11-14T03:44:08Z,"An ancient region covering a large part of southwestern Europe, ...",<p>The Barrington Atlas Directory notes: FRA</p>,Barrington Atlas: BAtlas 1 D1 Gallia,Gallia,https://pleiades.stoa.org/places/993,993,46.360953305773286,1.6706144893053327,"POLYGON ((9.6708805 31.937048, ...))",rough
Five things in those two lines already determine parts of the configuration:
There is a header row, which the loader does not skip on its own (
skip_rows: 1).Fields are comma-separated and quoted, and some quoted fields contain commas and even line breaks — so quoting must stay enabled (the default).
idis a stable numeric identifier, and it is the same number that appears in theuri. That is ouridentifier.titleis the display name, andrepresentative_latitude/representative_longitudeare our coordinates.Coordinates are plain decimal degrees, so this source’s coordinate system is EPSG:4326, the same one the gazetteer stores. The provinces file will turn out not to be.
Two answers are not in this file at all: the alternate names live in names.csv and the place types in places_place_types.csv. That is normal for data exported from a relational database, and joining those files back together is the bulk of the work below.
It is also worth asking what counts as a place here. Pleiades includes regions, rivers, roads, and ethnic groups alongside settlements, and about 7,500 of its places have no coordinates at all because they are attested in texts but have never been located. We will keep all of them: an unlocated place is still worth finding by name.
Step 2: Get One Source to Build#
Resist the temptation to write the whole configuration at once. Start with a single source, an identifier, and one name, confirm that it builds, and add one thing at a time. Debugging a small configuration that just broke is far easier than debugging a large one that has never worked.
Save this as pleiades.yaml:
name: pleiades
crs: EPSG:4326 # default
sources:
- name: places
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: places.csv
delimiter: ","
quote: '"' # default
skip_rows: 1 # header row; default is 0
crs: EPSG:4326 # default (the gazetteer's crs)
attributes:
- name: "created"
type: text
- name: "description"
type: text
- name: "details"
type: text
- name: "provenance"
type: text
- name: "title"
type: text
- name: "uri"
type: text
- name: "id"
type: integer
- name: "representative_latitude"
type: real
- name: "representative_longitude"
type: real
- name: "bounding_box_wkt"
type: text
- name: "location_precision"
type: text
features:
- source: places
identifier: "id"
names:
- "title"
Five things about the source declaration deserve attention, because they are where first attempts usually go wrong:
urlpoints at the archive, andfilenames the file to take out of it. The archive is downloaded and unpacked automatically; you do not unpack it yourself, and you do not need to know where inside the archive the file sits. Later steps add more sources from the same archive, and it is downloaded only once per build.Every column of a delimited file must be declared, in file order, whether or not you use it. Declaring fewer columns than the file has does not drop the extras — it makes the file unparseable.
created,details, andprovenanceare declared here purely to account for their position.Each column declares a
type(text,integer,real, orgeometry). Choosetextwhen unsure: anintegercolumn that turns out to contain a non-numeric value anywhere in the file will abort the build.quoteandskip_rowsdescribe the text format of a delimited file, and only exist for such files. Both are written out here for clarity, butquote: '"'is what you get anyway.crsnames the coordinate system this source’s coordinates are in. It is spelled out here to show where it goes; since it is the same as the gazetteer’s, it changes nothing. Step 8 adds a source where it does.
Tip
Downloads are not kept between builds, so every step below would fetch the 35 MB archive again. Since you already have it from step 1, point the sources at your local copy while you iterate — path: pleiades_gis_data.zip instead of the url: line, resolved relative to the configuration file — and switch back to url when you are done. Everything else works identically.
Install it:
python -m geoparser install pleiades.yaml
─────────────────────────────────── pleiades ───────────────────────────────────
Prepared sources ━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:08
Compiled features ━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:01
Built artifact ━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:01
Summary
Features 42,242
Names 42,242
That is a real, queryable gazetteer:
from geoparser import Gazetteer
gazetteer = Gazetteer("pleiades")
feature = gazetteer.find("433032")
print(feature.names) # ['Pompeii']
print(feature.data) # {}
print(feature.geometry) # None
42,242 features and exactly one name each, which matches the row count of places.csv. Getting the counts you expect at this stage is the single most useful check in the whole process: if the feature count is wrong now, the problem is in the source declaration, not in anything you add later.
Step 3: Decide What Goes into data#
data is the feature’s attribute dictionary, and you control it entirely. Each entry is written the way it would appear in a SQL SELECT list: a column name stores that column under its own name, and an optional trailing AS <alias> renames it.
features:
- source: places
identifier: "id"
names:
- "title"
data:
- "title"
- "location_precision"
- "description"
- "uri"
{
"title": "Pompeii",
"location_precision": "precise",
"description": "An ancient city of Campania destroyed by the volcanic eruption of Mt. Vesuvius in A.D. 79, ...",
"uri": "https://pleiades.stoa.org/places/433032"
}
What to include is a judgement call, guided by who reads it. Attributes exist to help a human or a resolver tell two places with the same name apart, and to point back at the source record. Type, hierarchy, and dates do that; internal revision timestamps and provenance notes generally do not, and they make the artifact bigger for nothing. description is worth its size here because Pleiades’ descriptions are genuinely informative, and uri is worth including in almost any gazetteer, because it lets anyone using your data get back to the original record.
Step 4: Add Geometry#
A feature’s geometry is a single value: either a geometry column of a spatial source, or an expression that constructs one. Here we build a point from the two coordinate columns:
features:
- source: places
identifier: "id"
geometry: "ST_Point(representative_longitude, representative_latitude)"
names:
- "title"
data:
- "title"
- "representative_latitude AS latitude"
- "representative_longitude AS longitude"
- "location_precision"
- "description"
- "uri"
Warning
ST_Point takes longitude first, then latitude. Swapping them is the most common mistake in a gazetteer configuration, and it fails silently: the build succeeds, and the places end up mirrored across the globe. Check one place you know before moving on — Pompeii should be at roughly 14.49 E, 40.75 N, not 40.75 E, 14.49 N.
The build now reports the same 42,242 features, of which 34,678 have a geometry. The remaining 7,564 are the unlocated places, whose coordinate columns are empty; their geometry is simply NULL and they remain fully searchable. Storing the raw coordinates in data as well is redundant with the geometry, but convenient for anything that reads attributes rather than geometry.
gazetteer = Gazetteer("pleiades")
feature = gazetteer.find("433032")
print(feature.geometry) # POINT (14.485429 40.74941)
print(feature.crs) # EPSG:4326
Step 5: Add Names from a Second File#
A gazetteer is only as good as its names, and so far each place has exactly one. The real names are in names.csv, one row per name, each pointing at a place through place_id:
place_id title language_tag attested_form romanized_form_1
433032 Pompeii la Pompeii
433032 Pompeia grc Πομπηία Pompeia
433032 Pompei it Pompei Pompei
433032 Pompei la Pompei Pompei
433032 Colonia … la Colonia … Colonia …
To reach them, the feature block joins that file. A join is written as a raw SQL join clause, appended to the block’s source; the whole joined table becomes available, and you pick what you need from it afterwards. Declare names as a second source — all nineteen columns, in file order, exactly as for places — and then join it:
sources:
- name: places
# ... as before ...
- name: names
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: names.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "created"
type: text
- name: "description"
type: text
- name: "details"
type: text
- name: "provenance"
type: text
- name: "title"
type: text
- name: "uri"
type: text
- name: "id"
type: text
- name: "place_id"
type: integer
- name: "name_type"
type: text
- name: "language_tag"
type: text
- name: "attested_form"
type: text
- name: "romanized_form_1"
type: text
- name: "romanized_form_2"
type: text
- name: "romanized_form_3"
type: text
- name: "association_certainty"
type: text
- name: "transcription_accuracy"
type: text
- name: "transcription_completeness"
type: text
- name: "year_after_which"
type: integer
- name: "year_before_which"
type: integer
features:
- source: places
joins:
- "LEFT JOIN names n ON id = n.place_id"
identifier: "id"
geometry: "ST_Point(representative_longitude, representative_latitude)"
names:
- "title"
- "n.romanized_form_1"
- "n.romanized_form_2"
- "n.romanized_form_3"
- "n.attested_form"
data:
- "title"
# ... as before ...
Note that id is text in this file and integer in places.csv. Each source is declared on its own terms: the type describes the column in that file, and here it holds a slug rather than a number. What has to match is the pair of columns the join compares, places.id and names.place_id, both integers.
Two conventions make join clauses short. Give every joined table a short alias (n here) and refer to its columns through it (n.attested_form). Columns of the block’s own source are written bare (id, title), everywhere in the block including inside the join condition — you never write a prefix for them. Use LEFT JOIN rather than JOIN unless you deliberately want to drop places that have no match: an inner join here would silently discard the 15,301 places that have no row in names.csv.
The name count rises from 42,242 to 77,923, and Pompeii now carries its Latin, Greek, and Italian names:
print(gazetteer.find("433032").names)
# ['Colonia Cornelia Veneria Pompeianorum', 'Pompei', 'Pompeia', 'Pompeii', 'Πομπηία']
Warning
A one-to-many join multiplies rows, and that changes what ``data`` means. After this join, Pompeii is five rows rather than one. Names are collected across all of them, which is exactly what we want. Data values are not: each one is taken from the first row of the group, and among rows that a join fanned out, “first” is arbitrary. Reading n.language_tag into data would therefore store one unpredictable language per place. Use a one-to-many join to gather names; get attributes from the place’s own columns or by aggregating explicitly, as in the next step.
Step 6: Clean Up the Names#
Names sometimes need work before they are usable, because source data mixes names with editorial notation. A quick look through Pleiades titles shows three patterns:
Visurgis (river) qualifier in parentheses (4,295 titles)
Sigoulones? uncertain identification (1,373 titles)
Bisutun/Bagistana/Vastan?/Baptana alternative readings, slash-separated (2,226 titles)
[Kangavar]/Concobar reconstructed form in brackets (152 titles)
No text mentioning the Weser will call it “Visurgis (river)”, so a feature whose only name carries a qualifier is effectively unfindable. Each names entry may be any scalar SQL expression, which is how you fix this. Build the expression up in pieces rather than all at once — strip the notation, then split what remains on the slashes, and let unnest turn the resulting list into one name per element:
features:
- source: places
# ... as before ...
names:
- "title"
- >-
unnest(string_split(regexp_replace(title,
'\s*\([^)]*\)|\?|\[|\]', '', 'g'), '/'))
- "n.romanized_form_1"
- "n.romanized_form_2"
- "n.romanized_form_3"
- "n.attested_form"
That single entry produces, for the four titles above, Visurgis; Sigoulones; Bisutun, Bagistana, Vastan, Baptana; and Kangavar, Concobar. The raw title is kept as a name too, so nothing is lost if the notation happens to be part of the real name. Duplicates and empty results are dropped automatically, so expressions like this are safe to be generous with.
Tip
The >- is YAML’s folded block scalar: it joins the following lines into one string. Long expressions become far easier to read that way, and — unlike a quoted string — backslashes need no doubling, so regular expressions can be written exactly as SQL sees them.
The name count rises to 79,578. Whether an expression like this is worth writing depends on your data; the way to find out is to sort your name column and read a few hundred values, which takes ten minutes and tells you more than any amount of guessing.
Step 7: Aggregate a Many-to-Many Relation#
Place types are the single most useful attribute for telling same-named places apart, and in Pleiades they sit behind two more files: places_place_types.csv maps places onto type keys, and place_types.csv translates those keys into readable terms. A place may have several types.
Declare both files first, since nothing can reference a source that does not exist yet:
sources:
# ... places and names ...
- name: places_place_types
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: places_place_types.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "place_id"
type: integer
- name: "place_type"
type: text
- name: place_types
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: place_types.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "key"
type: text
- name: "term"
type: text
- name: "definition"
type: text
- name: "same_as"
type: text
- name: "uri"
type: text
Neither of them will be named in any features block. A source that only supports a join or an expression still has to be declared, and simply never backs features of its own.
The obvious way to use them is to join both files and read the term:
features:
- source: places
# Don't do this
joins:
- "LEFT JOIN names n ON id = n.place_id"
- "LEFT JOIN places_place_types x ON id = x.place_id"
- "LEFT JOIN place_types t ON x.place_type = t.key"
data:
- "t.term AS place_type"
This builds, and it is wrong in the way the previous step warned about. Pompeii is both a settlement and an urban area; the join fans it out and data keeps one of the two, unpredictably. It also demonstrates a chained join — the second clause joins to a table the first one brought in — which is the right pattern when the relation is many-to-one (a code and its label), just not here.
What we want is all of a place’s types in one value. Because data entries are arbitrary scalar expressions, a subquery can aggregate the relation without fanning out any rows, and the two joins can go away again:
features:
- source: places
joins:
- "LEFT JOIN names n ON id = n.place_id"
# ... identifier, geometry and names as before ...
data:
- "title"
- >-
(SELECT string_agg(DISTINCT coalesce(t.term, x.place_type), ', '
ORDER BY coalesce(t.term, x.place_type))
FROM places_place_types x
LEFT JOIN place_types t ON x.place_type = t.key
WHERE x.place_id = id)
AS place_types
- "representative_latitude AS latitude"
- "representative_longitude AS longitude"
- "location_precision"
- "description"
- "uri"
The subquery reads the bridge table for one place (WHERE x.place_id = id, where id is the current place’s own column), looks each key up in the vocabulary, and joins the results into a single string. It is an ordinary SQL query with its own FROM and its own join, and the only thing tying it to the feature being built is that one reference to id.
The coalesce is there because 1,904 rows of the bridge table reference keys that are missing from the vocabulary file entirely; without it, those places would silently lose a type. The ORDER BY is not cosmetic either: without it the aggregation order is unspecified, and rebuilding the same configuration would produce different strings for multi-type places. Pompeii now gets "settlement, urban area", stably.
Step 8: Join a Spatial Source#
Pleiades has no administrative hierarchy — no “in Italy, in Campania” to disambiguate with. We can compute one instead: given polygons of the Roman provinces, a place’s province is whichever polygon contains its point. This is a spatial join, and it is the main reason to bring a second dataset in.
A spatial source is any file the build can read geometry from — Shapefile, GeoPackage, GeoJSON, and other GDAL-supported formats. It is distinguished from a tabular source by having no delimiter, and it declares exactly one attribute of type geometry, always named geometry:
sources:
# ... the four Pleiades files ...
- name: provinces
url: https://urbesetorbis.com/downloads/empire2.geojson
file: empire2.geojson
crs: EPSG:3857 # Web Mercator, unlike the rest
attributes:
- name: "fid"
type: integer
- name: "Title"
type: text
- name: "Government"
type: text
- name: "StartYear"
type: integer
- name: "geometry"
type: geometry
Three differences from a tabular source matter. The two text-format keys are gone: a spatial format carries its own field names, so there is no header row to skip and nothing to unquote, and declaring skip_rows or quote on such a source is an error rather than a no-op. Unlike a delimited file, a spatial source also selects its fields by name, so it may declare a subset of them, in any order: this file additionally carries a color field, which is simply left out. And crs finally does something, because this file is in Web Mercator rather than the degrees the gazetteer stores.
That last point is worth dwelling on, because in most geospatial tooling it is where the work starts. Here it is where it ends: declaring crs: EPSG:3857 is the whole of it. Geometries are re-projected into the gazetteer’s coordinate system as the source is read, before any of your expressions see them, so from the configuration’s point of view every geometry in every source is already in the same system. Now the join:
features:
- source: places
joins:
- "LEFT JOIN names n ON id = n.place_id"
- >-
LEFT JOIN provinces p
ON ST_Within(ST_Point(representative_longitude, representative_latitude),
p.geometry)
# ... identifier, geometry and names as before ...
data:
# ... title and place_types as before ...
- "p.Title AS province"
- "p.Government AS province_government"
# ... the remaining attributes as before ...
A spatial join reads exactly like an attribute join, except that the condition is a spatial predicate — ST_Within, ST_Intersects, ST_Contains, and so on — instead of an equality. Here it asks which province polygon contains the place’s point, with no coordinate handling of any kind: degrees on the left, degrees on the right, because the polygons were converted on the way in. For lines and polygons, reduce one side to a representative point with ST_Centroid if the predicate needs it.
Note
The one geometry that is not converted for you is one you build yourself out of plain number columns, such as ST_Point(x, y) on a source whose crs is not the gazetteer’s. As a feature’s geometry it is converted like any other; inside a join condition you have to write ST_Transform around it yourself. It does not come up here, since Pleiades’ coordinates are already in degrees.
26,887 of the 34,678 located places fall inside a province; the rest are outside the empire, or in it at a different date. Because provinces is a many-to-one relation, reading two of its columns into data is safe here.
Step 9: Consider a Second Kind of Place#
So far every feature comes from one file. A configuration may instead have as many features blocks as it has sources, each projecting a different file, with its own identifier scheme, geometry, names, and attributes. This is how a gazetteer holds genuinely different kinds of place — settlements from one dataset, administrative areas from another — in one artifact.
The provinces would be the obvious candidate here, since ancient texts name them constantly. A block over that source would look like this:
features:
- source: places
# ... the block from step 8 ...
- source: provinces
identifier: "'province:' || fid"
geometry: "geometry"
names:
- "Title"
- "unnest(string_split(Title, ' et '))"
data:
- "Title AS title"
- "'Roman province' AS place_types"
- "Government AS government"
- "StartYear AS start_year"
Four things are worth pointing out in those ten lines, because they are what a second block always has to get right:
Identifiers must be unique across the whole gazetteer, not just within a block. The provinces’ own
fidvalues are 1 to 44, which would collide with Pleiades place ids; the expression prefixes them, givingprovince:1and so on. If two blocks ever do produce the same identifier, the build fails and names the collision rather than silently merging two places.geometry: "geometry"takes the polygon straight from the spatial source, in place of a point built from coordinates. Nothing else about the block changes because its geometry happens to be a MultiPolygon.Several provinces are administrative pairings, so
unnest(string_split(Title, ' et '))makesCretaandCyrenaicafindable alongsideCreta et Cyrenaica.'Roman province' AS place_typesstores a constant. Blocks are free to store completely different attributes, and usually do — but it is worth agreeing on a few keys, heretitleandplace_types, so that anything reading the gazetteer finds them on every feature whatever it came from.
This gazetteer nevertheless does without that block, for a reason worth checking before you add one of your own: Pleiades already contains the provinces. Sicilia (Roman province), Dacia (province) and the rest are places in places.csv, with descriptions, alternative names, and Pleiades ids. Adding the polygons as features would duplicate every one of them under a second identifier, so a text mentioning Sicilia would produce two candidates for the same province, differing only in whether it is drawn as a point or an area. In this gazetteer the provinces dataset earns its place as the boundaries that locate other places, which is what step 8 uses it for, and not as a second set of places.
Add a second block when its source contributes places the first one does not have. If your boundaries came from a dataset with no counterpart in your main file, the block above is exactly what you would write.
Step 10: Use the Finished Gazetteer#
The configuration is now complete; it is reproduced in full at the end of this walkthrough. Installed, it takes well under a minute and 0.6 GB of working disk space, and produces an artifact of about 23 MB with 42,242 features and 79,578 names. That measured figure is what the file’s disk key declares, so that a build with too little room to finish says so before it starts rather than halfway through:
python -m geoparser install pleiades.yaml
python -m geoparser list
Check it from the outside before trusting it. Look up places you know and confirm the names, attributes, and coordinates are what you expect:
from geoparser import Gazetteer
gazetteer = Gazetteer("pleiades")
for feature in gazetteer.search("Sicilia", method="exact"):
geometry = feature.geometry.geom_type if feature.geometry else "no geometry"
print(feature.identifier, feature.data["title"],
"|", feature.data["place_types"], "|", geometry)
462492 Sicilia (island) | island | Point
981549 Sicilia (Roman province) | province | Point
The gazetteer is now finished, and everything earlier in this guide applies to it. What it takes to resolve against it depends entirely on the resolver: each one uses a gazetteer in its own way, and some need to be told something about your attributes before they can. The SentenceTransformerResolver, for instance, describes candidates in words and has to be given the keys that description is built from. Configuring Modules documents what each resolver expects.
Step 11: Expect to Retune the Modules#
A finished gazetteer is not the end of the work, because the default recognizer and resolver were not chosen with your data in mind. Two mismatches show up immediately with a gazetteer as far from the defaults as this one:
The recognizer may not find your placenames. The default spaCy model was trained on contemporary news text, and in “Pliny describes the eruption that buried Pompeii and Herculaneum in Campania” it labels
Campaniaas a place but missesPompeiiandHerculaneumentirely. What the gazetteer contains is irrelevant if nothing is recognized to look up. Try a larger spaCy model, a model trained on your domain, or supply the spans yourself with a manual recognizer.The resolver’s threshold may be tuned for other data. The default embedding model is fine-tuned on GeoNames-style descriptions, so descriptions like
Campania (region) in Italiasit lower on its similarity scale than the defaultmin_similarityof 0.6 expects: the correct candidate scores 0.55 and is rejected, after which the resolver widens its search and settles on a worse one. Lowering the threshold to 0.45 resolvesCampaniacorrectly.
Neither is a fault in the configuration, and neither is visible from the build output — which is why it is worth resolving a handful of names you know the answer to, and inspecting the candidates and their scores when one comes out wrong. Configuring Modules covers the parameters, and Training Modules covers fine-tuning a resolver against your own gazetteer, which is the real fix: the default models are optimized for GeoNames, and training on data annotated with your gazetteer’s features is the recommended way to close the gap.
The Complete Configuration#
Here is everything the walkthrough built, in one file — pleiades.yaml:
name: pleiades
crs: EPSG:4326 # default
disk: 600000000 # 0.6 GB, measured
sources:
- name: places
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: places.csv
delimiter: ","
quote: '"' # default
skip_rows: 1 # header row; default is 0
crs: EPSG:4326 # default (the gazetteer's crs)
attributes:
- name: "created"
type: text
- name: "description"
type: text
- name: "details"
type: text
- name: "provenance"
type: text
- name: "title"
type: text
- name: "uri"
type: text
- name: "id"
type: integer
- name: "representative_latitude"
type: real
- name: "representative_longitude"
type: real
- name: "bounding_box_wkt"
type: text
- name: "location_precision"
type: text
- name: names
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: names.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "created"
type: text
- name: "description"
type: text
- name: "details"
type: text
- name: "provenance"
type: text
- name: "title"
type: text
- name: "uri"
type: text
- name: "id"
type: text
- name: "place_id"
type: integer
- name: "name_type"
type: text
- name: "language_tag"
type: text
- name: "attested_form"
type: text
- name: "romanized_form_1"
type: text
- name: "romanized_form_2"
type: text
- name: "romanized_form_3"
type: text
- name: "association_certainty"
type: text
- name: "transcription_accuracy"
type: text
- name: "transcription_completeness"
type: text
- name: "year_after_which"
type: integer
- name: "year_before_which"
type: integer
- name: places_place_types
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: places_place_types.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "place_id"
type: integer
- name: "place_type"
type: text
- name: place_types
url: https://atlantides.org/downloads/pleiades/gis/pleiades_gis_data.zip
file: place_types.csv
delimiter: ","
quote: '"' # default
skip_rows: 1
crs: EPSG:4326 # default
attributes:
- name: "key"
type: text
- name: "term"
type: text
- name: "definition"
type: text
- name: "same_as"
type: text
- name: "uri"
type: text
- name: provinces
url: https://urbesetorbis.com/downloads/empire2.geojson
file: empire2.geojson
crs: EPSG:3857 # Web Mercator, unlike the rest
attributes:
- name: "fid"
type: integer
- name: "Title"
type: text
- name: "Government"
type: text
- name: "StartYear"
type: integer
- name: "geometry"
type: geometry
features:
- source: places
joins:
- "LEFT JOIN names n ON id = n.place_id"
- >-
LEFT JOIN provinces p
ON ST_Within(ST_Point(representative_longitude, representative_latitude),
p.geometry)
identifier: "id"
geometry: "ST_Point(representative_longitude, representative_latitude)"
names:
- "title"
- >-
unnest(string_split(regexp_replace(title,
'\s*\([^)]*\)|\?|\[|\]', '', 'g'), '/'))
- "n.romanized_form_1"
- "n.romanized_form_2"
- "n.romanized_form_3"
- "n.attested_form"
data:
- "title"
- >-
(SELECT string_agg(DISTINCT coalesce(t.term, x.place_type), ', '
ORDER BY coalesce(t.term, x.place_type))
FROM places_place_types x
LEFT JOIN place_types t ON x.place_type = t.key
WHERE x.place_id = id)
AS place_types
- "p.Title AS province"
- "p.Government AS province_government"
- "representative_latitude AS latitude"
- "representative_longitude AS longitude"
- "location_precision"
- "description"
- "uri"
At this point you have used every mechanism the configuration format offers: tabular and spatial sources, archives and plain files, attribute joins, chained joins, one-to-many joins, spatial joins across coordinate systems, derived names, aggregated attributes, and — at least on paper — a second feature block. The reference below fills in the details.
Configuration Reference#
Every key the configuration format accepts. The walkthrough above is the way to learn the format; this is what to consult once you are writing your own.
A configuration has two top-level lists. sources declares the files to read and what is in them. features declares how the rows of a source become places. Sources are transient — they are staged for the build and discarded — so the features blocks are what actually shapes the gazetteer.
Top-Level Keys#
Key |
Meaning |
|---|---|
|
The gazetteer’s name, used to install, query, and uninstall it. Letters, digits, underscores, and hyphens. Installing a configuration replaces any artifact of the same name. |
|
Coordinate reference system all geometries are stored in. Defaults to |
|
Optional. Free bytes required on the gazetteers volume, checked before the build starts. Leave it out until you have measured what a build actually costs; a guessed value either blocks builds that would have worked or fails to catch the ones that will not. |
|
List of source declarations. At least one. |
|
List of feature blocks. At least one. |
Sources#
A source is one file to read. It is tabular if it declares a delimiter, and spatial otherwise.
Key |
Meaning |
|---|---|
|
Identifies the source within the configuration, and is the name used to join it. Must be a valid SQL identifier: letters, digits, and underscores, not starting with a digit. |
|
Where to download the file from. Exactly one of |
|
A local file or directory instead of a download. Relative paths resolve against the configuration file’s own directory, so a configuration and its data can be moved together. |
|
The file to actually read. When |
|
Field separator of a delimited text file ( |
|
Quote character for tabular sources, |
|
Leading lines of a tabular file to discard: |
|
Coordinate reference system this source’s geometry and coordinates are in. Defaults to the gazetteer’s |
|
The source’s columns, each with a |
quote and skip_rows describe a delimited text file and are rejected on a spatial source. (delimiter cannot be, since declaring it is what makes a source tabular in the first place.) Attribute types are text, integer, real, and geometry, and two rules differ between the two kinds of source:
A tabular source must declare every column, in file order, and may not declare a
geometryattribute. The declaration is the file’s schema, so a mismatch in count makes the file unparseable rather than dropping columns.A spatial source may declare any subset of the file’s fields, in any order, and must declare exactly one attribute of type
geometry, namedgeometry.
Several sources may point at the same url with different file values, which is how a multi-file archive is used; it is downloaded once.
Feature Blocks#
Each block turns the rows of one source into features. A source backs at most one block, and the block’s source name is what appears as feature.source in the artifact.
Key |
Meaning |
|---|---|
|
The source whose rows this block projects. |
|
Optional list of raw SQL join clauses that widen those rows with columns from other sources. |
|
The feature’s stable identifier. Must read only the block’s own source. Rows where it evaluates to |
|
Optional. The feature’s geometry, as a geometry column or an expression building one. Must read only the block’s own source. |
|
One or more names, each a column or expression. At least one is required. |
|
Optional attributes, each written as it would appear in a SQL |
Blocks are written in reading order — source, joins, then everything derived from them.
Values and Expressions#
identifier, geometry, every names entry, and every data entry is either a column reference or a scalar SQL expression, and one rule covers all of them: a bare name is a column of the block’s own source; a column of a joined source is written ``<alias>.<column>``. The same rule applies inside join conditions, so nothing in a block ever needs a prefix for its own columns.
Expressions are evaluated by DuckDB, so its scalar function library is available: string manipulation, CASE, arithmetic, regular expressions, ST_ spatial constructors, and subqueries over any declared source. Some patterns that come up repeatedly:
# Names
- "name" # a column
- "unnest(string_split(alternatenames, ','))" # one name per value of a multi-value column
- "regexp_replace(name, '\\s*\\(.*\\)', '')" # strip a parenthesised qualifier
- "n.attested_form" # a column of a joined source
# Geometry
- "geometry" # a spatial source's geometry column
- "ST_Point(longitude, latitude)" # built from coordinate columns (longitude first)
- "ST_GeomFromText(geometry_wkt)" # parsed from a WKT text column
# Data
- "population" # stored under its own name
- "c.Country AS country_name" # a joined column, renamed
- "upper(name) AS name_upper" # an expression (alias required)
- "'Roman province' AS place_types" # a constant
A data entry that is a plain column reference may omit the alias, in which case the column’s own name is the key; anything else has no name of its own and must be given one. Two entries may not store the same key.
A name expression may return a list, in which case each element becomes its own name — that is what unnest is for. Names that come out NULL, empty, or whitespace are dropped, and duplicates are collapsed, so name expressions can be written generously.
Joins#
A join is a raw SQL join clause appended to the block’s source. The whole joined table becomes available; there is no separate list of columns to import, you simply reference what you need in data.
joins:
# Attribute join: match on equal values
- "LEFT JOIN countryInfo c ON country_code = c.ISO"
# Match on an expression
- "LEFT JOIN admin1CodesASCII a1 ON country_code || '.' || admin1_code = a1.code"
# Chained join: reference a table joined earlier
- "LEFT JOIN admin2Codes a2 ON a1.code || '.' || admin2_code = a2.code"
# Spatial join
- "LEFT JOIN municipalities g ON ST_Within(ST_Centroid(geometry), g.geometry)"
Joins are applied in order, so a later clause may reference any table an earlier one brought in; that is how multi-level hierarchies (place → municipality → district → canton) are expressed. Prefer LEFT JOIN: an inner join drops the rows that have no match, which quietly removes places from your gazetteer.
The property of joins that causes most of the surprises is cardinality, because it changes what data means. A many-to-one join is safe. A one-to-many join multiplies the rows of a place, and while names are collected across all of them, each data value is taken from the first row of the group — arbitrary among rows produced by a fan-out. Gather names with a one-to-many join; aggregate attributes with a subquery instead.
Coordinate systems, on the other hand, take care of themselves. Every source’s geometry is converted to the gazetteer’s crs as it is read, so a spatial join between sources published in different systems needs nothing written for it. The exception is a geometry you construct from plain number columns, such as ST_Point(x, y) over a source whose crs is not the gazetteer’s: as a feature’s geometry it is converted, but inside a join condition you have to wrap it in ST_Transform yourself.
Duplicate Identifiers#
Within one block, rows that share an identifier are merged into a single feature: all their names are collected, their geometries are unioned into one possibly multi-part geometry, and each data value is taken from the first row. This is automatic, and it is how datasets that spread a place over several records — multi-part geometries, one row per name — end up as one place.
p1 North Summit 800 → one feature "p1", names {North Summit, South Summit},
p1 South Summit 1200 height 800, geometry MultiPoint of both rows
p2 Lone Hill 300 → one feature "p2"
Across blocks it is an error instead: identifiers must be unique in the whole gazetteer, and a collision fails the build with the offending identifier and the blocks it came from. Namespace them with an expression ("'province:' || fid") or merge the blocks.
What the Format Does Not Do#
Knowing the limits saves time looking for keys that do not exist:
There is no row filter. A block has no
where. To exclude rows, make theidentifierevaluate toNULLfor them, since rows without an identifier are skipped:identifier: "CASE WHEN feature_class <> 'X' THEN id END". To filter joined rows, add the condition to the join instead:"LEFT JOIN names n ON id = n.place_id AND n.association_certainty = 'certain'".``identifier`` and ``geometry`` must come from the block’s own source. Only
namesanddatacan read joined columns: a place’s identity and location are properties of its own record, and joins exist to describe a place rather than to decide which places there are. A qualified reference in either is rejected with an explicit message when the feature blocks are compiled — which is after the sources have been prepared, so on a large dataset the files are downloaded and staged before you see the error.One block per source. To project one file into two kinds of feature, declare it twice under different source names.
No user code. Transformations are limited to SQL expressions evaluated during the build. Anything that needs real preprocessing has to happen before the build, on a file you then reference with
path.Names are unordered and unlabelled. There is no notion of a preferred name or a name’s language in the search index. Store that in
dataif you need it.Unrecognized keys are ignored, not reported. A key the format does not know — including a misspelling of one it does — is silently dropped, so
skiprows: 1validates cleanly and skips nothing. If a setting appears to have no effect, check its spelling against the tables above first.
Installing and Iterating#
Install a configuration by pointing the same command at the file instead of a pre-configured name:
python -m geoparser install path/to/my_gazetteer.yaml
It then behaves exactly like a pre-configured gazetteer:
python -m geoparser list
python -m geoparser uninstall my_gazetteer
The build validates the configuration, acquires the files, runs the projections, and writes the artifact. If anything is wrong, it stops with a message and nothing is installed; a successful build atomically replaces any previous artifact of the same name, so iterating on a configuration is safe.
Downloaded files are discarded once the build finishes, which means every rebuild fetches them again. While you are still changing a configuration, download the files once by hand and point the sources at them with path instead of url; each iteration then costs only the processing time.
Note
Building a gazetteer with geometries needs DuckDB’s spatial extension, which is fetched automatically the first time. To build offline, run one spatial build while connected first so the extension is cached.
Further Examples#
The pre-configured gazetteers are built exactly the same way, and their files are worth reading once you have your own working: geonames.yaml (a large tabular dataset with four lookup joins) and swissnames3d.yaml (six spatial sources, chained spatial joins, and multi-part geometry merging).