Short answer
QGIS labels become powerful the moment you stop labelling a single field and start writing expressions. An expression lets you concatenate several attributes, suppress nulls, round numbers and append units, build multi-line stacked labels, and switch text on or off by zoom or scale. The label expression is set in Layer Styling > Labels > Single Labels, in the box next to the field selector (click the ε / expression button). Everything below is a recipe you can paste and adapt; the only discipline that matters is handling null values explicitly so the map never prints the literal word "NULL".
The two ways to join text, and why it matters
There are two operators for combining strings, and they behave differently with missing data:
||concatenates but propagates NULL:"unit" || ' ' || "age"returns NULL (a blank label) if either field is empty.concat()concatenates and ignores NULL:concat("unit", ' ', "age")prints whatever fields exist.
For real geological data, where age or member is often blank, concat() is almost always the safer default. When you do want NULL to suppress the whole label (e.g. only label features that have a name), || is the right tool precisely because it returns nothing.
A robust base label:
concat(
"map_unit",
if("member" IS NOT NULL, '\n' || "member", ''),
if("confidence" = 'inferred', ' (?)', '')
)
This prints the unit code, drops onto a second line for the member if present, and adds a question mark for inferred units. The \n inserts a line break (enable multi-line in the label settings or it shows as a literal).
Cleaning numbers and adding units
Floating-point columns print ugly tails — a dip of 34.0000019 is not what you want on a structural map. Use format_number() to round and control decimals, then attach the unit:
concat(format_number("dip_deg", 0), '°/', format_number("strike_deg", 0), '°')
That yields something like 34°/120°. For thickness in metres with one decimal: concat(format_number("thick_m", 1), ' m'). To suppress a zero value entirely:
if("thick_m" > 0, concat(format_number("thick_m", 1), ' m'), '')
For very large numbers, format_number() also adds thousands separators according to your project locale, which is usually what you want for areas or volumes.
Handling nulls deliberately
Three patterns cover almost every case:
- Fallback text:
coalesce("formation", 'unmapped')prints "unmapped" instead of nothing. - Skip the part: wrap it in
concat(), which drops NULL silently. - Skip the whole label:
if("show_label" = 1, "name", '')returns an empty string, so QGIS draws no label for that feature — cleaner than relying on the renderer to filter.
The reason nulls cause trouble is that || and arithmetic both treat NULL as "unknown", so a single missing field poisons the entire expression. Decide per label which behaviour you want.
Stacked, formatted, and conditional labels
QGIS labels support HTML formatting when you tick Allow HTML formatting in the text settings. That unlocks subscripts and superscripts, which geology needs constantly:
'CaCO<sub>3</sub>'
renders with a proper subscript 3. You can also bold or colour part of a label inline. Combine with a stacked layout for a structural symbol caption:
concat(
'<b>', "unit_code", '</b>',
'<br>', format_number("dip_deg", 0), '°'
)
For scale-dependent detail, use the map-canvas variable so dense labels only appear when zoomed in:
if(@map_scale < 25000,
concat("unit_code", '\n', "lithology"),
"unit_code")
At 1:25,000 and larger you get unit plus lithology; zoomed out, just the code. This is far cleaner than maintaining two label layers.
Worked example: a formation label with provenance
You want each polygon labelled with its code, the formation name when known, the dominant dip, and a marker for low-confidence mapping — but nothing should print for water bodies (map_unit = 'H2O').
if("map_unit" = 'H2O', '',
concat(
"map_unit",
if("formation" IS NOT NULL, '\n' || title("formation"), ''),
if("dip_deg" IS NOT NULL, '\n' || format_number("dip_deg", 0) || '°', ''),
if("confidence" IN ('inferred','concealed'), ' (?)', '')
)
)
title() capitalises the formation name consistently regardless of how it was entered, and the outer if() returns an empty string for water so no label is drawn.
Common pitfalls and why they happen
- Labels read "NULL" or vanish unexpectedly. Cause: using
||on a field that contains nulls. Fix: switch toconcat()or wrap withcoalesce(). - Line breaks show as literal "\n". Cause: multi-line not enabled, or
\ntyped inside double quotes (a field reference) instead of single quotes (a string). Fix: use single quotes for literals and enable wrapping. - HTML tags print as text. Cause: Allow HTML formatting is off. Fix: enable it in the text format panel.
- Numbers show 12 decimals. Cause: raw float field. Fix:
format_number()orround(). - Too many labels overlap. Cause: every feature is labelled. Fix: use a scale-based
if(), set label priority, or enable collision-aware placement and Show all labels only where needed. - Wrong field referenced silently. Cause: single vs double quotes confusion —
'dip_deg'is the literal text,"dip_deg"is the field. The expression preview at the bottom of the dialog catches this immediately.
Validation
Use the expression dialog's live preview, which evaluates against the first feature, then step through a few features with the Identify tool to confirm nulls, water bodies, and inferred units all render as intended. Toggle the project scale to verify scale-dependent rules fire at the right thresholds, and export a test PDF to confirm subscripts and line breaks survive the layout engine.
Bathyl perspective
Labels are interpretation made visible, so we treat the expression as part of the data model rather than a cosmetic afterthought. Null-safe concatenation, rounded units, and scale-aware detail mean a structural map reads correctly at every zoom and never advertises a missing attribute. The expression is also self-documenting: anyone opening the project can see exactly what each label encodes.
Related reading
- QGIS Package Project for Delivery
- QGIS Export to Web Map Formats
- QGIS Field Calculator for GIS Cleanup
- GIS and spatial analysis