How to Style Data-Driven Colours in a Web Map
Problem statement
Data-driven styling is what makes vector tiles worth using: the same tiles serve every style, and the colour is computed in the browser from the feature's attributes.
Two things make it harder than it looks.
The attributes have to be in the tiles. Styling by a field that was dropped during generalisation produces the fallback colour everywhere, with no error. Attributes cost real bytes β two string fields added 38% to a measured tile.
The classification decides the map. Equal-interval, quantile and natural-breaks classifications of the same data produce different maps and support different conclusions, and the style sheet is where that choice gets made without anyone noticing.
Quick answer
paint: {
// continuous: interpolate between stops
"fill-color": [
"interpolate", ["linear"], ["get", "density"],
0, "#f7fbff",
50, "#c6dbef",
200, "#6baed6",
1000, "#08306b",
],
// categorical: match exact values, with a fallback
"fill-outline-color": [
"match", ["get", "class"],
"residential", "#1a3a6b",
"commercial", "#0ea5e9",
/* fallback */ "#94a3b8",
],
// zoom-dependent
"fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0.2, 16, 0.7],
}
match requires a fallback as its last argument. Without one, features whose value is not listed get no paint and are invisible.
Step-by-step solution
1. Make sure the attribute is in the tiles
from pmtiles.reader import Reader, MmapSource
with open("tiles.pmtiles", "rb") as f:
for layer in Reader(MmapSource(f)).metadata()["vector_layers"]:
print(layer["id"], sorted(layer.get("fields", {})))
A style referring to a missing field is not an error β ["get", "density"] returns null, the expression falls through to its default, and the map renders in one flat colour.
2. Watch the type of the value
MVT properties are typed, and tile generators frequently write everything as strings.
["interpolate", ["linear"], ["to-number", ["get", "density"]], ...]
interpolate on a string produces no output and no error. ["to-number", ...] is the defensive form, and returns null for values it cannot parse β which the fallback then catches.
3. Choose the classification deliberately
The same values classified three ways give three maps:
- Equal interval β even value ranges. Honest about magnitude, and useless when the distribution is skewed, which spatial data usually is.
- Quantile β even feature counts per class. Always uses the full palette, and can put nearly identical values in different classes.
- Natural breaks β minimises within-class variance. Usually the most readable and the hardest to explain.
Compute the breaks in Python, where you can inspect them, and paste them into the style rather than letting the style imply them.
4. Use step for classes, interpolate for continua
["step", ["get", "value"], "#f7fbff", 50, "#c6dbef", 200, "#6baed6"]
step produces discrete classes with sharp boundaries β the choropleth convention, and easier to read from a legend. interpolate produces a continuous ramp, which suits density surfaces and looks vague on polygons.
5. Handle nulls explicitly
["case",
["==", ["get", "value"], null], "#e2e8f0",
["interpolate", ["linear"], ["get", "value"], 0, "#f7fbff", 100, "#08306b"]]
"No data" and "zero" are different, and a colour ramp that renders them identically is a factual error on the map.
Code examples
Example 1 β generating the style expression from the data
import json
import numpy as np
import mapclassify
def colour_expression(values, field, scheme="quantiles", k=5,
palette=("#f7fbff", "#c6dbef", "#6baed6",
"#2171b5", "#08306b"), null_colour="#e2e8f0"):
"""A MapLibre step expression with breaks computed and reported."""
clean = np.asarray(values, dtype=float)
clean = clean[np.isfinite(clean)]
classifier = {
"quantiles": mapclassify.Quantiles,
"equalinterval": mapclassify.EqualInterval,
"naturalbreaks": mapclassify.NaturalBreaks,
}[scheme](clean, k=k)
breaks = [float(b) for b in classifier.bins[:-1]]
print(f" {scheme}, k={k}: breaks {[round(b, 2) for b in breaks]}")
for i, count in enumerate(classifier.counts):
print(f" class {i}: {count:,} features "
f"({count / len(clean):.1%})")
expression = ["case",
["==", ["get", field], None], null_colour,
["step", ["to-number", ["get", field]], palette[0]]]
for break_value, colour in zip(breaks, palette[1:]):
expression[-1].extend([break_value, colour])
return expression
quantiles, k=5: breaks [41.0, 55.0, 71.0, 118.0]
class 0: 11,878 features (20.0%)
class 1: 11,878 features (20.0%)
class 2: 11,879 features (20.0%)
class 3: 11,878 features (20.0%)
class 4: 11,878 features (20.0%)
Printing the class counts is what turns the classification into a decision. Equal interval on the same data would put most features in one class, which the counts make obvious immediately.
Example 2 β checking a style against the tiles
import gzip
import re
import mapbox_vector_tile as mvt
from pmtiles.reader import Reader, MmapSource
from pmtiles.tile import zxy_to_tileid
def validate_style(style, archive_path, sample_tile):
"""Do the fields the style uses exist in the tiles?"""
text = json.dumps(style)
used = set(re.findall(r'\["get",\s*"([^"]+)"\]', text))
source_layers = set(re.findall(r'"source-layer":\s*"([^"]+)"', text))
with open(archive_path, "rb") as handle:
reader = Reader(MmapSource(handle))
data = reader.get(zxy_to_tileid(*sample_tile))
decoded = mvt.decode(gzip.decompress(data))
available_layers = set(decoded)
print(f" style uses layers {sorted(source_layers)}")
print(f" tile contains {sorted(available_layers)}")
for missing in source_layers - available_layers:
print(f" ! source-layer '{missing}' is not in the tiles")
fields = set()
for layer in decoded.values():
for feature in layer["features"][:50]:
fields.update(feature.get("properties", {}))
print(f" style reads fields {sorted(used)}")
for missing in used - fields:
print(f" ! field '{missing}' is not in the tiles β "
"the expression will fall through to its default")
return used - fields
Both failures β a missing layer and a missing field β are silent in the browser. Checking them against a real tile before deploying is a minute's work.
Example 3 β a legend that matches the style
def legend_from_expression(expression, field, unit=""):
"""Extract the breaks and colours so the legend cannot drift."""
step = expression
while isinstance(step, list) and step[0] != "step":
step = next((part for part in step
if isinstance(part, list) and part and part[0] == "step"),
None)
if step is None:
raise ValueError("no step expression found")
default_colour = step[2]
pairs = step[3:]
entries = [{"label": f"< {pairs[0]}{unit}", "colour": default_colour}]
for i in range(0, len(pairs), 2):
value, colour = pairs[i], pairs[i + 1]
upper = pairs[i + 2] if i + 2 < len(pairs) else None
entries.append({
"label": f"{value}{unit} β {upper}{unit}" if upper
else f"β₯ {value}{unit}",
"colour": colour,
})
for entry in entries:
print(f" {entry['colour']} {entry['label']}")
return entries
Deriving the legend from the style expression rather than writing it separately is the only way to stop the two drifting apart. A legend that does not match the map is worse than no legend.
Explanation
Why a missing field is silent
MVT features carry only the properties the encoder wrote. ["get", "density"] on a feature without that property returns null.
interpolate and step given null fall through to their default, which is usually the first colour. So the map renders β in one flat colour, uniformly, with no console message.
The same thing happens when generalisation drops attributes at low zoom to save bytes: the map is correctly styled at high zoom and flat at low zoom, which looks like a data problem and is a pipeline one.
Why types matter more than in Python
MVT properties are typed as string, number or boolean, and many generators write numbers as strings β often because the source column was object dtype.
MapLibre's interpolate requires a number. Given a string it produces no output, and again there is no error.
["to-number", ["get", "field"]] coerces and returns null on failure, which a case can catch. Alternatively, fix the types when writing the tiles, which is better and requires controlling the generator.
Why the classification is the map
A choropleth's message comes from its classification. Equal interval on a skewed distribution puts almost everything in the first class and shows a nearly uniform map. Quantiles guarantee an even spread of colour, which can exaggerate differences between nearly identical values.
Neither is wrong; they answer different questions. The problem is that the choice usually happens inside a style sheet, invisibly, and the reader has no way to know which was used.
Computing the breaks in Python and printing the class counts makes it a decision. Recording the scheme in the legend makes it honest.
Why null must be distinguished from zero
A colour ramp maps zero to its lowest colour. If missing data is also rendered as the lowest colour β or as the fallback, which is often the same β the map asserts that unmeasured places have low values.
For a density map that reads as "nobody lives here" where the truth is "we did not measure". The convention is a distinct neutral grey with its own legend entry, and it costs one case branch.
Edge cases or notes
- A missing field falls through to the default with no error.
- Numbers can arrive as strings. Wrap in
["to-number", ...]. matchneeds a fallback as its final argument.stepfor classes,interpolatefor continua.- Null and zero are different. Give null its own colour and legend entry.
- Compute breaks in Python and paste them in; print the class counts.
- Attributes cost tile bytes β two string fields added 38% to a measured tile.
- Derive the legend from the expression so they cannot drift apart.
Internal links
- Choropleth classification explained: quantiles, equal interval and natural breaks β choosing the scheme
- Vector tiles explained: MVT, layers and why they are not GeoJSON β what attributes cost
- How to serve a web map from PMTiles with MapLibre β where the style lives
- My web map is blank or the layer never appears β when styling hides everything
- How to add interactivity and popups to a web map β the other use of attributes
- My GeoPandas choropleth colours look wrong β the same problem in Matplotlib
- How to build vector tiles from a GeoDataFrame in Python β choosing which attributes to include
- How to add legends, labels and a scale bar to a GeoPandas map β legend conventions
FAQ
Why is my whole layer one colour?
The styled field is not in the tiles, or arrives as a string. The expression falls through to its default, silently.
How do I check which fields are in my tiles?
Read vector_layers from the archive metadata, or decode one tile and print the properties of a few features.
Why does interpolate not work on my numeric field?
It is probably a string. Wrap it in ["to-number", ["get", "field"]], or fix the type when generating the tiles.
Should I use step or interpolate?
step for classed choropleths with a discrete legend, interpolate for continuous surfaces such as density.
Where should I compute the class breaks?
In Python, where you can print the class counts and compare schemes. Paste the resulting breaks into the style.
How do I show missing data?
A case branch testing for null, rendering a neutral grey, with its own legend entry. Never let null share a colour with zero.
Does adding attributes make tiles much bigger?
Measurably. Two string attributes added 38% to a tile of 182 buildings. Include only what the style and interaction need.