Data-Driven Color Ramps with Interpolate Expressions

Use ["interpolate", ["linear"], ["get", "value"], stop, color, …] for a continuous color ramp and ["step", ["get", "value"], default, stop, color, …] for a classed choropleth — both bind directly to one numeric attribute in the tile and are evaluated per feature by the renderer, so the ramp changes with a style edit and never a tile rebuild.

When to use interpolate vs step vs match

The same attribute rendered three waysThree panels showing a population attribute rendered with interpolate as a continuous gradient, with step as discrete bands, and with match as categorical colours.THREE RAMPSinterpolateContinuous gradientEvery value has its owncolourReader comparesmagnitudesBest for continuousmeasuresstepDiscrete bands at setbreaksValues inside a band lookidenticalReader classifiesBest for thresholds andgradesmatchOne colour per namedcategoryNo ordering impliedUnmatched values hit thefallbackBest for categoricalattributes
The data does not choose the operator — the question the reader is asking does. Comparison wants a gradient; classification wants bands.

The choice is driven entirely by how the source attribute is distributed and how you want the legend to read.

Expression Use when the attribute is Legend reads as Example
["interpolate", ["linear"], …] Continuous numeric with meaningful in-between values A smooth gradient bar Temperature, elevation, normalized density
["interpolate", ["exponential", b], …] Continuous but heavily skewed (a few very large values) A gradient weighted toward the low end Population density, income, traffic counts
["step", …] Continuous numeric you want to bucket into classes Discrete swatches with hard breaks Quantile choropleth, risk bands
["match", ["get", key], …] A categorical code or label, not a magnitude Discrete swatches, unordered Land-use class, zoning code, party

["interpolate"] and ["step"] both require a number as input. ["match"] is the correct operator for categorical strings and is covered in binding data-driven properties to vector layers; reaching for ["match"] on a continuous value forces you to enumerate every discrete value, which does not scale.

Specification detail

The four parts of an interpolate expressionAn interpolate expression split into its interpolation type, the input expression, and the alternating stop and output pairs.EXPRESSION ANATOMYinterpolationlinear / exponentialexponential needs a baseinput["get", "pop"]must evaluate to anumberstop, outputrepeated pairsascending stops, anyoutput typeno fallbackby designwrap in coalesce fornulls
Stops must be in ascending order. Out-of-order stops do not error — they produce a ramp that reverses direction partway through.
text
["interpolate", <interpolation>, <input>, <stop1>, <output1>, <stop2>, <output2>, …]
Element Values Notes
<interpolation> ["linear"] Constant rate between stops
["exponential", base] base > 1 biases growth toward higher stops; base < 1 toward lower. base = 1 is identical to linear
["cubic-bezier", x1, y1, x2, y2] Custom easing curve; rarely needed for color
<input> ["get", "value"] Must resolve to a number. Wrap in ["coalesce", …, sentinel] for null safety
<stopN> ascending numbers Must be strictly increasing literal numbers, not expressions
<outputN> CSS color / number For a color ramp these are colors; MapLibre interpolates in the color space of the values

["step"] has a slightly different shape — the value before the first stop is the default, given up front:

text
["step", <input>, <default_output>, <stop1>, <output1>, <stop2>, <output2>, …]

Values below the first stop take the default; each stop is a lower bound (inclusive) for its output. ["interpolate"] instead clamps: inputs below the first stop get the first output, inputs above the last stop get the last output. There is no fallback slot in either operator, so a null input does not hit a default — it produces null and the feature renders with the layer’s own default paint. That is why the fallback belongs on the input via ["coalesce"], not as a trailing argument. All operators here are supported in MapLibre GL JS 1.0+ and Mapbox GL JS 0.41+.

Production layer definition

The block below is a complete fill layer with a five-stop exponential ramp bound to a pop_density attribute (people per km²), plus a commented step variant for a classed choropleth. Paste it into the layers array of a MapLibre GL JSON style document.

json
{
  "id": "density-choropleth",
  "type": "fill",
  "source": "census",
  "source-layer": "districts",
  "paint": {
    "fill-color": [
      "interpolate",
      ["exponential", 1.4],
      ["coalesce", ["get", "pop_density"], -1],
      -1,    "#d9d9d9",
      0,     "#fef0d9",
      1000,  "#fdcc8a",
      4000,  "#fc8d59",
      12000, "#e34a33",
      30000, "#b30000"
    ],
    "fill-opacity": 0.85,
    "fill-outline-color": "#ffffff"
  }
}

The -1 stop is a dedicated sentinel: the ["coalesce"] converts any missing or null pop_density into -1, which maps to a neutral grey so no-data districts read as “no data” rather than as the lowest real class. Because the ramp is exponential with base 1.4, most of the color range is spent on the dense low-to-mid values where the data actually clusters, instead of being flattened by a handful of very dense districts.

For a classed choropleth with hard breaks (for example, matching a printed legend), swap fill-color for a ["step"]:

json
"fill-color": [
  "step",
  ["coalesce", ["get", "pop_density"], -1],
  "#d9d9d9",
  0,     "#fef0d9",
  1000,  "#fdcc8a",
  4000,  "#fc8d59",
  12000, "#e34a33",
  30000, "#b30000"
]

Same breakpoints, same colors, but every district within a band is a single flat swatch — no blending across the break. The default #d9d9d9 (given right after the input) now catches the -1 sentinel because -1 falls below the first stop of 0.

Interaction effects

The attribute must exist and be numeric in the tile schema. A ramp is only as reliable as the encoding step feeding it. If pop_density was dropped during tiling, every feature falls through to ["coalesce"]'s sentinel and the whole layer renders grey. Whitelist the key with Tippecanoe’s -y pop_density and force its type with -T pop_density:float, following the attribute filtering rules that govern which properties survive encoding. This is the same retention discipline described in binding data-driven properties to vector layers, applied to a single numeric ramp input.

Precompute the numeric code upstream. If your ramp input is derived — a per-district quantile class, a normalized index, a log of a raw count — compute it once in the pipeline and store it as a plain numeric column rather than reconstructing it in the expression. A ["step"] over a precomputed density_class integer (0–5) is far cheaper to evaluate and easier to keep synchronized with your legend than an ["interpolate"] that recomputes classification on every feature.

Renaming the source layer breaks the binding silently. The ramp reads source-layer: "districts"; if the tile build renames that layer, the expression still parses but paints nothing. Keep the style and tile schema aligned through the same discipline used when syncing MapLibre filters with renamed source layers, and treat the layer name as a contract in the broader layer filter synchronization workflow.

Feature-state for interactive ramps. When the ramp value comes from a runtime join (a fetched metric, a user-selected year) rather than the baked tile attribute, swap ["get", "pop_density"] for ["coalesce", ["feature-state", "metric"], ["get", "pop_density"], -1]. The renderer re-evaluates the ramp when you call map.setFeatureState(...), giving live recoloring with no tile round-trip.

Performance impact

Stop count costs little; legibility costs a lotFour ramps measured for relative evaluation cost at two, five, nine and twenty stops, alongside the point at which a legend stops being readable.STOP COUNTevaluation cost, relative to 2 stops2 stops1.0x5 stops1.08x9 stops1.17x20 stops1.41x
The performance argument for few stops is weak. The legibility argument is decisive: past about seven bands a reader cannot match a colour back to a value.
Factor Effect Guideline
Interpolation type ["exponential"] costs the same as ["linear"] at draw time Choose by data shape, not speed
Number of stops Each stop is a comparison; cost is roughly linear in stop count 4–7 stops covers almost every legend; past ~12 the legend is unreadable before it is slow
Input type Numeric ["get"] is a direct dictionary read; a string input that must be coerced is slower and often fails Encode the ramp attribute as a number in the tile
["coalesce"] wrapper One extra null check per feature; negligible Always keep it — the correctness gain dwarfs the cost
Recolor trigger A ramp edit is style-only Bump the style version, never the tile URL, when only colors or breakpoints change

Keeping stops few and the input numeric is the whole performance story here. The expensive mistake is not the ramp — it is a non-numeric input that forces per-feature coercion or silently defeats the ramp entirely.

Common mistakes

1. String value where a number is needed — the ramp collapses to one color

Symptom: Every feature renders the same color (usually the first stop), with no gradient. The console may show Expected value to be of type number, but found string.

Cause: pop_density is encoded as a quoted string ("4200") instead of a number, so ["interpolate"] cannot order it against the numeric stops.

Fix: Coerce at encoding time and verify:

bash
tippecanoe -o census.mbtiles -y pop_density -T pop_density:float districts.geojson
tippecanoe-decode census.mbtiles 8 72 96 | \
  jq '.features[0].properties.pop_density | type'   # must print "number"

If you cannot re-tile immediately, coerce in the expression with ["to-number", ["get", "pop_density"]] as a stopgap — but fix the encoding, because ["to-number"] returns 0 for unparseable strings and quietly biases the low end of the ramp.

2. Missing fallback — null features vanish into the base paint

Symptom: Districts with no data render with the layer default (often transparent or black), not a “no-data” color, leaving holes in the choropleth.

Cause: ["interpolate"] and ["step"] have no fallback slot; a null input yields null, and MapLibre falls back to the property’s own default.

Fix: Wrap the input in ["coalesce", ["get", "pop_density"], -1] and add a stop (interpolate) or rely on the default output (step) for the -1 sentinel, exactly as in the production block above.

3. Too many stops — an unreadable legend and brittle breakpoints

Symptom: A 15-stop ramp that is impossible to map to a legend and shifts unpredictably when the data range changes.

Cause: Treating ["interpolate"] like a lookup table instead of a ramp.

Fix: Reduce to 5–6 perceptually spaced stops, or move to ["step"] with quantile breaks computed once upstream. Fewer, well-chosen breakpoints read better and survive data updates without a full re-tune.


Parent: Dynamic Attribute Mapping

FAQ

How many stops should a ramp have?

Five to seven. The performance cost of more is small; the legibility cost is decisive, because past about seven bands a reader cannot match a colour back to a value.

Why did my ramp collapse to one colour?

The input evaluated to a string. interpolate needs a number, and a numeric-looking string from the source produces the first stop’s colour for every feature.

Does interpolate have a fallback?

No, by design. Wrap the input in coalesce with a sensible default, or null-valued features fall through to the layer’s base paint.

Should stops be evenly spaced?

Only if the data is. For skewed distributions, quantile breaks read far better than equal intervals — the ramp should follow the data, not the arithmetic.