Writing a Tilemaker Lua Profile for OSM Tags Jump to heading
Write the one file that decides everything about what your tiles contain — and write it so that somebody who did not write it can tell what it does and prove it is working.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A Tilemaker profile is two files working together. A JSON configuration declares the layers, each with a name and a zoom range, plus global settings. A Lua script implements callbacks that Tilemaker invokes per element: one for nodes, one for ways, and relation handling that decides which relations to accept and how their members contribute.
Inside a callback, three functions do the work. Find(key) reads a tag. Layer(name, isArea) assigns the current element to a layer. Attribute(key, value) writes an attribute onto the emitted feature. There is also MinZoom(z), which sets the minimum zoom for this feature, overriding the layer-wide setting — and that is the function that turns a crude layer-level schema into a properly ranked one.
The callback is invoked once per element in the extract. Everything about how you write it is governed by that: no file access, no compiled patterns inside the function, no growing tables.
Runnable solution Jump to heading
-- profile.lua — layer assignment for an OSM base map.
-- Called once per element: keep every path constant-time.
-- Closed vocabularies, declared once at load time, never rebuilt per element.
local ROAD_CLASS = {
motorway = "motorway", motorway_link = "motorway",
trunk = "trunk", trunk_link = "trunk",
primary = "primary", primary_link = "primary",
secondary = "secondary", secondary_link = "secondary",
tertiary = "tertiary", tertiary_link = "tertiary",
unclassified = "minor", residential = "minor",
service = "service", track = "track",
}
local ROAD_MINZOOM = {
motorway = 4, trunk = 5, primary = 7, secondary = 9,
tertiary = 11, minor = 13, service = 14, track = 14,
}
local WATER_KEYS = { natural = "water", landuse = "reservoir", waterway = "riverbank" }
-- Match counters, reported at the end so an empty layer is impossible to miss.
local counts = {}
local function tally(layer)
counts[layer] = (counts[layer] or 0) + 1
end
function node_function()
local place = Find("place")
if place == "" then return end -- the early exit: most nodes stop here
local minzoom = ({ city = 6, town = 9, village = 11, hamlet = 13 })[place]
if minzoom == nil then return end
Layer("place", false)
Attribute("class", place)
Attribute("name", Find("name"))
MinZoom(minzoom)
tally("place")
end
function way_function()
local highway = Find("highway")
if highway ~= "" then
local class = ROAD_CLASS[highway]
if class == nil then return end -- unknown road type: not rendered
Layer("transportation", false)
Attribute("class", class)
-- A road carrying a reference number outranks its class by one level.
local minzoom = ROAD_MINZOOM[class]
if Find("ref") ~= "" then minzoom = math.max(3, minzoom - 1) end
Attribute("rank", minzoom)
MinZoom(minzoom)
tally("transportation")
return
end
for key, value in pairs(WATER_KEYS) do
if Find(key) == value then
Layer("water", true) -- true: this is an area
Attribute("class", "water")
MinZoom(4)
tally("water")
return
end
end
local landuse = Find("landuse")
if landuse ~= "" then
Layer("landuse", true)
Attribute("class", landuse)
MinZoom(9)
tally("landuse")
end
end
-- Relations: without these two, every multipolygon lake and forest is dropped.
function relation_scan_function()
if Find("type") == "multipolygon" then Accept() end
end
function relation_function()
local natural = Find("natural")
if natural == "water" then
Layer("water", true)
Attribute("class", "water")
MinZoom(4)
tally("water")
elseif Find("landuse") ~= "" then
Layer("landuse", true)
Attribute("class", Find("landuse"))
MinZoom(9)
tally("landuse")
end
end
function exit_function()
for layer, n in pairs(counts) do
print(string.format("layer %-15s %d feature(s)", layer, n))
end
end
{
"layers": {
"transportation": { "minzoom": 4, "maxzoom": 14 },
"water": { "minzoom": 4, "maxzoom": 14, "simplify_below": 12 },
"landuse": { "minzoom": 9, "maxzoom": 14, "simplify_below": 12 },
"place": { "minzoom": 6, "maxzoom": 14 }
},
"settings": {
"minzoom": 0, "maxzoom": 14,
"basezoom": 14, "include_ids": false,
"name": "OSM base map",
"attribution": "© OpenStreetMap contributors"
}
}
Step-by-step walkthrough Jump to heading
- Declare vocabularies at load time. The lookup tables are built once when the script is loaded, not per element. A table constructed inside a callback would be allocated hundreds of millions of times.
- Exit early and often. The first line of each callback reads one tag and returns when it is absent. The overwhelming majority of elements in an extract take that path.
- Return an explicit nil for unknown values. A road class not in the vocabulary is not rendered rather than falling into a default, which keeps the schema closed and the tiles predictable.
- Set the area flag correctly.
Layer(name, true)declares an area; getting it wrong turns a lake outline into a line and a road into a polygon. - Override the zoom per feature. The layer’s JSON minimum zoom is a floor;
MinZoomon the feature is what makes a motorway appear at zoom 4 and a residential street at 13 within one layer. - Handle relations in two functions. The scan function decides which relations to accept, and the main function assigns the accepted ones. Omitting either silently drops every multipolygon.
- Count matches per layer. The tally and the exit report are four lines and turn “the map looks wrong” into “the water layer matched zero features”.
- Keep attribution in the configuration. It belongs in the archive’s metadata, which the settings block populates.
Verification Jump to heading
- Every layer reports a non-zero count. The exit report is the fastest possible check that a branch is reachable.
- A known feature appears at its intended zoom. Pick a specific motorway and a specific residential street and confirm both.
- Areas are areas. Decode a tile and confirm water features are polygons, not lines.
- Multipolygon lakes are present. Find a large lake mapped as a relation; if it is missing, relation handling is not firing.
- The build is not profile-bound. Compare the run time against a trivial profile on the same extract; a large gap points at per-element work.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Large lakes missing | Relation callbacks not implemented | Accept multipolygons in the scan and assign them |
| Water renders as outlines | Area flag not set on the layer call | Pass the area flag as true for polygon features |
| Build unexpectedly slow | Table or pattern built inside a callback | Declare all lookup tables at script load time |
| A layer is empty | Branch never reached for real data | Add per-layer counters and read the exit report |
| Everything appears at once | Only layer-level zoom ranges used | Set a per-feature minimum zoom from a rank |
| Unknown road types rendered oddly | Fallback default for unmatched values | Return without emitting when the class is unknown |
| Attribution missing | Settings block incomplete | Set name and attribution in the configuration |
Specification reference Jump to heading
A Tilemaker profile consists of a JSON configuration declaring layers with their zoom ranges and a Lua script implementing
node_function,way_function, and the relation scanning and processing functions. Within these,Findreads tags,Layerassigns the element to a layer with an area flag,Attributewrites an attribute, andMinZoomoverrides the layer’s minimum zoom for the current feature. See the Tilemaker documentation for the full callback list and configuration keys.
Frequently Asked Questions Jump to heading
Why are my multipolygon areas missing?
Because relations need their own handling, in two parts: a scanning function that decides which relations to accept, and a processing function that assigns the accepted ones to layers. A profile copied from a minimal example usually implements only the node and way callbacks, which silently drops every area mapped as a multipolygon relation. Most large lakes, forests and complex buildings are mapped that way, so the map looks complete until somebody notices every big water body is absent.
Should the zoom range live in the JSON or in the Lua?
Both, doing different jobs. The JSON layer range is a floor and ceiling for the whole layer, which the archive’s metadata advertises to clients. The per-feature override in Lua is what distinguishes a motorway from a driveway within that range. Using only the JSON gives a map where everything in a layer appears at once; using only the override leaves the metadata describing a wider range than the data occupies.
How do I keep the profile fast?
Declare every lookup table at script load time, return as early as possible, and make sure the first thing each callback does is the cheapest test that rejects most elements. The callback runs once per element, so anything allocating or compiling inside it is multiplied by hundreds of millions. Comparing a run against a trivial profile on the same extract tells you immediately whether the profile or the tool is the limit.
Should a way and a relation with the same tags be handled identically?
Yes, and the reliable way to guarantee it is a shared helper called from both callbacks. Duplicating the classification logic works initially and then drifts: somebody adds a land use class to the way path and forgets the relation path, and the map ends up rendering that class only where it happens to be mapped as a closed way. One function, two call sites.
Related Jump to heading
- Planetiler & Tilemaker Workflows — the parent topic and the comparison behind choosing this tool.
- Running Planetiler on a Regional Extract — the sibling tool with a compiled profile.
- Tuning Tippecanoe Zoom and Feature Dropping — the same ranking idea in the GeoJSON pipeline.
- Understanding OSM Multipolygon Relations for GIS — what the relation callbacks are assembling.
- Mapping OSM Tags to a Fixed Schema with YAML — the same closed-vocabulary discipline as configuration.
Up one level: Planetiler & Tilemaker Workflows.