Loading OSM Data into PostGIS with osm2pgsql Flex Jump to heading
Define your own PostGIS schema in Lua and load an OSM extract into it, in a shape that a minutely diff can still update afterwards.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
osm2pgsql has two output backends. The legacy pgsql backend writes a fixed set of tables with a fixed column choice; the flex backend hands you a Lua file and executes it. Everything about the resulting schema comes from that file.
A style file does two things. It declares tables with osm2pgsql.define_table, and it supplies callbacks — process_node, process_way, process_relation — that run once per object and decide whether to insert a row. An object your callbacks ignore simply does not appear, which is the intended behaviour and the reason a misspelled tag key produces an empty table rather than an error.
The one declaration that is easy to treat as bookkeeping and is not is ids.
Without an ids block the table is write-once: osm2pgsql --append has no way to find the rows belonging to a modified object, so it leaves the table alone and reports nothing. This is the single most common reason a diff-updated database stops tracking one of its tables while the others keep working.
Runnable solution Jump to heading
A style file producing three tables — points of interest, a road network, and building polygons — all of them updatable:
-- osm.lua — flex style file. Every table here is updatable by --append.
local srid = 4326
local pois = osm2pgsql.define_table({
name = 'poi',
ids = { type = 'node', id_column = 'osm_id' },
columns = {
{ column = 'name', type = 'text' },
{ column = 'category', type = 'text', not_null = true },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'point', projection = srid, not_null = true },
},
})
local roads = osm2pgsql.define_table({
name = 'road',
ids = { type = 'way', id_column = 'osm_id' },
columns = {
{ column = 'name', type = 'text' },
{ column = 'highway', type = 'text', not_null = true },
{ column = 'oneway', type = 'bool' },
{ column = 'maxspeed', type = 'int' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'linestring', projection = srid, not_null = true },
},
})
local buildings = osm2pgsql.define_table({
-- 'area' covers both closed ways and multipolygon relations; relation ids
-- arrive negative, which is why osm_id is signed.
name = 'building',
ids = { type = 'area', id_column = 'osm_id' },
columns = {
{ column = 'name', type = 'text' },
{ column = 'kind', type = 'text', not_null = true },
{ column = 'levels', type = 'int' },
{ column = 'tags', type = 'jsonb' },
{ column = 'geom', type = 'multipolygon', projection = srid, not_null = true },
},
})
-- Tags that describe the object rather than the thing: never worth a column.
local uninteresting = {
'source', 'source:date', 'attribution', 'created_by', 'note', 'fixme',
'odbl', 'import', 'converted_by',
}
local function clean(tags)
for _, key in ipairs(uninteresting) do tags[key] = nil end
return tags
end
local POI_KEYS = { 'amenity', 'shop', 'tourism', 'healthcare', 'office' }
local function poi_category(tags)
for _, key in ipairs(POI_KEYS) do
if tags[key] then return key .. '=' .. tags[key] end
end
return nil
end
-- "30 mph" and "50" both have to become an integer in one unit.
local function speed_kmh(value)
if not value then return nil end
local n, unit = value:match('^(%d+%.?%d*)%s*(%a*)$')
if not n then return nil end -- "walk", "RO:urban", ";50"
n = tonumber(n)
if unit:lower() == 'mph' then return math.floor(n * 1.609344 + 0.5) end
if unit == '' or unit:lower() == 'km/h' then return math.floor(n + 0.5) end
return nil
end
function osm2pgsql.process_node(object)
local category = poi_category(object.tags)
if not category then return end
pois:insert({
name = object.tags.name,
category = category,
tags = clean(object.tags),
geom = object:as_point(),
})
end
function osm2pgsql.process_way(object)
if object.tags.building and object.is_closed then
buildings:insert({
name = object.tags.name,
kind = object.tags.building,
levels = tonumber(object.tags['building:levels']),
tags = clean(object.tags),
geom = object:as_multipolygon(),
})
return
end
if object.tags.highway then
roads:insert({
name = object.tags.name,
highway = object.tags.highway,
oneway = object.tags.oneway == 'yes' or object.tags.oneway == '1',
maxspeed = speed_kmh(object.tags.maxspeed),
tags = clean(object.tags),
geom = object:as_linestring(),
})
end
end
function osm2pgsql.process_relation(object)
if object.tags.type == 'multipolygon' and object.tags.building then
buildings:insert({
name = object.tags.name,
kind = object.tags.building,
levels = tonumber(object.tags['building:levels']),
tags = clean(object.tags),
geom = object:as_multipolygon(),
})
end
end
The load itself, then the indexes:
createdb osm && psql -d osm -c 'CREATE EXTENSION postgis;'
osm2pgsql --create --slim \
--output=flex --style=osm.lua \
--cache=8000 --number-processes=4 \
-d osm ireland.osm.pbf
psql -d osm <<'SQL'
CREATE INDEX road_geom_idx ON road USING GIST (geom);
CREATE INDEX building_geom_idx ON building USING GIST (geom);
CREATE INDEX poi_geom_idx ON poi USING GIST (geom);
CREATE INDEX road_highway_idx ON road (highway);
ANALYZE road; ANALYZE building; ANALYZE poi;
SQL
Step-by-step walkthrough Jump to heading
define_table is evaluated once, at startup, and creates the table if it does not exist. not_null = true on the geometry column is worth setting on every table: it turns “this object had no usable geometry” from a null row into a loud error at insert time, which is where you want to find out.
process_way handles the fact that a closed way tagged building is a polygon while a way tagged highway is a line, and that these are different tables. The return after the building insert matters — a closed way carrying both tags would otherwise land in both tables, which is occasionally what you want and usually a bug.
speed_kmh is the kind of normalisation that belongs in the style file rather than downstream, because it happens once at load rather than on every query. It also demonstrates the honest failure mode: values it cannot parse become null rather than a guess, following the same provenance discipline as Batch Attribute Mapping Strategies.
process_relation catches multipolygon buildings. Without it, every building with a courtyard is missing from the table, because a relation is not a way and process_way never sees it — the same asymmetry described in Understanding OSM Multipolygon Relations for GIS.
--cache=8000 gives the node cache eight gigabytes; too little and the load thrashes, too much and the machine swaps. --slim writes the middle tables that make --append possible later.
Verification Jump to heading
Three checks, in order of how quickly they fail:
-- 1. Every table has rows, and the geometry is the type you declared.
SELECT 'road' AS t, count(*), ST_GeometryType(geom) AS gt FROM road GROUP BY 1,3
UNION ALL SELECT 'building', count(*), ST_GeometryType(geom) FROM building GROUP BY 1,3
UNION ALL SELECT 'poi', count(*), ST_GeometryType(geom) FROM poi GROUP BY 1,3;
-- 2. The middle tables exist — without them --append is a no-op.
SELECT tablename FROM pg_tables WHERE tablename LIKE 'planet_osm_%';
-- 3. Relation-derived buildings arrived, and they are the negative ids.
SELECT count(*) FILTER (WHERE osm_id < 0) AS from_relations,
count(*) FILTER (WHERE osm_id > 0) AS from_ways
FROM building;
The third query is the one that catches a missing process_relation: a real country extract has buildings from relations in the low single-digit percentages, and a count of exactly zero means they were never inserted.
Then prove the table is updatable before you rely on it:
osm2pgsql --append --slim --output=flex --style=osm.lua -d osm changes.osc.gz
Common errors and fixes Jump to heading
| Message or symptom | Root cause | Fix |
|---|---|---|
Need slim mode to update |
Loaded without --slim |
Reload with --slim; there is no retrofit |
--append runs, row counts never change |
Table declared without ids |
Add the ids block and reload |
| A table is empty | Callback never inserts — tag key typo | Log a counter in Lua; do not trust silence |
NOT NULL violation on geom |
Way is not closed, or relation is broken | Guard with object.is_closed; quarantine the rest |
| Buildings with courtyards missing | No process_relation |
Add it; multipolygons are relations |
| Import slows to a crawl part-way | Indexes present during load | Create indexes after, then ANALYZE |
maxspeed column all null |
Values carry units the parser rejects | Widen the parser, or keep the raw tag in tags |
Frequently Asked Questions Jump to heading
Can I convert a non-slim import to slim later?
No. The middle tables are populated during the load from data that is discarded afterwards, so there is nothing to reconstruct them from short of re-reading the PBF — which is the reload. Decide before importing whether the database will ever need to track upstream, and if there is any doubt, use --slim; the cost is disk, and the alternative is a multi-hour reimport at the moment you discover you need it.
Why is osm_id negative on some building rows?
Because an area table can be fed by both closed ways and multipolygon relations, and the two identifier spaces overlap — way 12345 and relation 12345 are different objects. osm2pgsql disambiguates by storing relation-derived areas as negative numbers. Any join from this table to a way-keyed table must filter on osm_id > 0, and any join to a relation-keyed one must negate.
Should the raw tags column be jsonb or hstore?
jsonb unless you have an existing hstore schema. It indexes as well with a GIN index, it nests if you ever need it to, and every client library speaks JSON. The one argument for hstore is slightly smaller storage on tag-heavy tables, which rarely outweighs the interoperability.
How do I add a column without reimporting?
Add it in SQL and in the style file, then re-run with --append over a diff — but understand what that does: only objects touched by that diff get the new column populated, so the table ends up partly filled. For a column that must be complete, the honest options are a reimport or a one-off UPDATE that derives the value from the tags column you kept.
Is flex slower than the legacy pgsql output?
Marginally, because your Lua runs per object. On the measurements above the callbacks account for roughly a fifth of the load, and a style file that does heavy string work per object can push that much higher. It buys a schema you designed rather than one you have to work around, which is almost always the better trade.
Specification reference Jump to heading
osm2pgsql.define_table({ name, ids, columns })creates or attaches a table. Theidsfield declares how OSM object identifiers map to rows and is required for the table to be updatable in append mode. Validtypevalues arenode,way,relation,areaandany;areastores way identifiers positive and relation identifiers negative in a single signed column.
Related Jump to heading
- Exporting OSM to GeoParquet & PostGIS — the topic this loader belongs to.
- Writing OSM Features to GeoParquet with PyArrow — the immutable sink, for comparison.
- Applying Minutely Diffs to a PostGIS Database — what the slim middle tables make possible.
- Understanding OSM Multipolygon Relations for GIS — why
process_relationis not optional. - Batch Attribute Mapping Strategies — where the tag-to-column decisions are recorded.
Up one level: Exporting OSM to GeoParquet & PostGIS.