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.

What happens to one OSM way as it passes through a profile Four steps for a single way. The callback reads the tags it cares about with a lookup per key. A classification step maps the raw value onto a small closed vocabulary, returning early when the element is of no interest. The layer step assigns the element to a named layer, declaring whether it is an area. The attribute step writes the classification and a rank onto the feature and sets a per-feature minimum zoom derived from that rank. Read, classify, assign, describe read tags one lookup per key return early if absent classify raw value to vocabulary a dozen classes assign layer name plus area flag the style's contract describe attributes and min zoom rank decides the zoom Returning early when the element is uninteresting is the single biggest performance decision in the whole profile.
Most elements in an extract exit at step one, which is why that step must be the cheapest thing in the file.

Runnable solution Jump to heading

lua
-- 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
json
{
  "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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Override the zoom per feature. The layer’s JSON minimum zoom is a floor; MinZoom on the feature is what makes a motorway appear at zoom 4 and a residential street at 13 within one layer.
  6. 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.
  7. 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”.
  8. Keep attribution in the configuration. It belongs in the archive’s metadata, which the settings block populates.
Which profile callback handles which OSM element, and what it must not forget A grid of four callbacks against what each one receives and the mistake most often made in it. The node callback receives tagged nodes and most often forgets to exit early, making the build slow. The way callback receives ways and most often sets the area flag incorrectly. The relation scan callback decides which relations to accept and is most often omitted entirely, dropping every multipolygon. The relation callback assigns accepted relations and most often duplicates logic already in the way callback without keeping it in step. Four callbacks, four characteristic mistakes Receives Usual mistake node_function tagged nodes no early exit way_function all ways wrong area flag relation_scan all relations omitted entirely relation_function accepted relations drifts from way logic The third row is the one that produces a map missing every large lake and forest while looking otherwise complete.
The fourth row argues for a shared helper: the same tags should classify identically whether they arrive on a way or a relation.
How the two configuration files divide responsibility Four layers describing where each decision lives. The JSON settings block holds global concerns: the overall zoom range, the archive name and the attribution text. The JSON layers block declares each layer's name and its floor and ceiling zooms, which the archive metadata advertises. The Lua vocabularies hold the closed sets mapping raw tag values onto classes, declared once at load time. The Lua callbacks hold the per-element decisions: which layer, which attributes and which minimum zoom. Four places a decision can live, and only one is right for each JSON settings Zoom range, name, attribution archive metadata JSON layers Layer names and zoom floors the style contract Lua vocabularies Tag value to class maps load time, not per element Lua callbacks Layer, attributes, min zoom per element, keep it cheap Putting a class map in a callback or a layer name in the Lua is how the two files drift out of step with each other.
Each band answers a different question, and the boundaries between them are what keep the profile reviewable.

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, Find reads tags, Layer assigns the element to a layer with an area flag, Attribute writes an attribute, and MinZoom overrides 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.

Up one level: Planetiler & Tilemaker Workflows.