Set Scale-Based Visibility in PyQGIS

A map that shows every layer at every zoom is unreadable at both ends: a wall of parcel boundaries at national scale, and a single coastline when you are looking at one street. Scale-based visibility fixes that by giving each layer a range of scales it is allowed to draw in — and because the layer is skipped entirely outside that range, it makes the map faster as well as clearer.

This recipe belongs to Map Themes & Layer Visibility in PyQGIS. It covers setting the range from Python, the minimum/maximum naming that confuses everybody exactly once, applying ranges per rule instead of per layer, restricting labels independently of the features, and checking what will be visible before an export.

A scale ladder for a multi-scale mapA horizontal axis runs from one to two million on the left, zoomed out, to one to one thousand on the right, zoomed in. Boundaries draw across the whole range. Roads appear from one to two hundred and fifty thousand inwards. Buildings and parcels appear only at the closest scales, so each zoom level shows an appropriate amount of detail.Each layer earns its place at some scales and not others1:2 000 0001:250 0001:25 0001:1 000zoomed out ← → zoomed inboundariesalways drawnroadsminimumScale = 250 000buildingsminimumScale = 25 000parcel labelslabels onlya layer outside its range is skipped, not drawn and discarded

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A project with layers at different levels of detail — a national boundary set and a parcel layer is the classic pairing.
  • A rough idea of the scales your map will actually be read at, since the ranges are only as good as that judgement.

Set a range on a layer

Three calls, and only one of them is obvious.

from qgis.core import QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]

layer.setScaleBasedVisibility(True)
layer.setMinimumScale(10000)     # hidden when zoomed OUT past 1:10 000
layer.setMaximumScale(500)       # hidden when zoomed IN past 1:500
layer.triggerRepaint()

Breakdown: setScaleBasedVisibility(True) is the master switch; set the scales without it and nothing happens, which is the most common reason this feature "does not work". The naming is the real hazard. A scale of 1:10 000 is numerically larger than 1:500 but shows less detail, and QGIS names the range by the denominator: minimum scale is the largest denominator, i.e. the zoomed-out limit. Read the two lines as "not drawn beyond 1:10 000 zoomed out" and "not drawn beyond 1:500 zoomed in" and the ambiguity disappears.

Passing 0 to either setter removes that end of the range, so a layer can have a zoomed-out limit and no zoomed-in limit. That asymmetric form is the common one: most detail layers want to disappear when the reader zooms out and to keep drawing however far they zoom in.

Read the range back safely

Reporting the current settings is where the naming bites a second time.

def describe_range(layer):
    if not layer.hasScaleBasedVisibility():
        return "always drawn"
    out = layer.minimumScale()
    into = layer.maximumScale()
    parts = []
    if out:
        parts.append(f"hidden above 1:{out:,.0f}")
    if into:
        parts.append(f"hidden below 1:{into:,.0f}")
    return "; ".join(parts) or "range enabled but unbounded"

Breakdown: hasScaleBasedVisibility() is the getter matching setScaleBasedVisibility(), and checking it first avoids reporting a range that is stored but inactive. Testing each bound for truthiness handles the 0-means-unbounded convention without a separate sentinel check. Producing a sentence rather than two numbers is worth the extra lines, because a table of raw minimum and maximum values is misread by almost everyone reviewing a project.

Per-rule ranges, when one layer needs several

A rule-based renderer attaches a scale range to each rule, so one layer can change how it draws rather than whether it draws.

from qgis.core import QgsRuleBasedRenderer, QgsSymbol

renderer = layer.renderer()
root_rule = renderer.rootRule()

detailed = root_rule.children()[0]
detailed.setMinimumScale(25000)
detailed.setMaximumScale(0)

simplified = root_rule.children()[1]
simplified.setMinimumScale(0)
simplified.setMaximumScale(25000)

layer.triggerRepaint()

Breakdown: Rules use the same denominator convention as layers, so the two rules here hand over at 1:25 000 — the detailed symbol takes everything zoomed in from there, the simplified one everything zoomed out. Setting the far end to 0 on each rule leaves it unbounded, giving a clean handover with no gap and no overlap. This is how a road layer draws full casings in town and plain lines at regional scale from a single layer, which keeps labels, attributes and edits in one place instead of two.

Two rules, one handover pointThe simplified rule covers scales from fully zoomed out to one to twenty five thousand. The detailed rule covers one to twenty five thousand and closer. Setting the far bound of each rule to zero leaves it unbounded, so the two ranges meet exactly with no gap where nothing draws.The layer never disappears — its symbol changeshandover at 1:25 000rule: simplifiedmin 0 · max 25 000rule: detailedmin 25 000 · max 0a plain linecasing and fill

Labels can have their own limits

Features and their labels rarely want the same range. Parcels can be useful long before their reference numbers are legible.

settings = layer.labeling().settings()
settings.scaleVisibility = True
settings.minimumScale = 2500
settings.maximumScale = 0

labeling = layer.labeling().clone()
labeling.setSettings(settings)
layer.setLabeling(labeling)
layer.triggerRepaint()

Breakdown: QgsPalLayerSettings carries its own scaleVisibility flag and its own pair of bounds, entirely independent of the layer's. Cloning the labeling object before setting it back is the safe pattern: settings retrieved from a live labeling object are a copy in some versions and a reference in others, and cloning removes the doubt. The result is a layer whose polygons appear at 1:25 000 and whose labels wait until 1:2 500, which is the arrangement almost every cadastral map ends up with. More on the surrounding settings in controlling label placement.

Building a scale ladder for a whole project

Applied one layer at a time, scale ranges drift out of alignment: two detail layers that ought to appear together end up with limits a factor of two apart because they were set months apart. Declaring the ladder once fixes that.

LADDER = {
    "overview":  (None,      None),      # always drawn
    "regional":  (1_000_000, None),
    "local":     (100_000,   None),
    "detail":    (10_000,    None),
    "survey":    (2_000,     None),
}

ASSIGNMENT = {
    "boundaries": "overview",
    "roads": "regional",
    "buildings": "local",
    "parcels": "detail",
    "utility points": "survey",
}

for layer_name, band in ASSIGNMENT.items():
    layer = QgsProject.instance().mapLayersByName(layer_name)[0]
    out, into = LADDER[band]
    layer.setScaleBasedVisibility(out is not None or into is not None)
    layer.setMinimumScale(out or 0)
    layer.setMaximumScale(into or 0)
    layer.triggerRepaint()

Breakdown: Naming the bands rather than the numbers is what makes the arrangement reviewable — a colleague can disagree with "parcels belong in the detail band" far more usefully than with "parcels have minimum scale 10 000". Using None in the table and converting to 0 at the boundary keeps the QGIS convention out of the declaration, so the table reads as intent rather than as API detail. Adding a layer later is one line in ASSIGNMENT, and the ladder stays consistent by construction.

A ladder also documents the map's design. Five bands roughly a factor of ten apart matches how people actually zoom, and a project whose layers all sit in two bands is usually a project where scale ranges were added reactively to fix one slow screen rather than designed.

Scale ranges inside a layout

A layout map item computes its own scale from its extent and its printed size, and that number is often nothing like the canvas scale — an A3 map of a whole county might be 1:120 000 while you were working at 1:5 000.

map_item = layout.itemById("main map")
print(f"the layout map will render at 1:{map_item.scale():,.0f}")

Breakdown: scale() returns the denominator the item will actually use, so pairing it with the visible_at() helper above answers "what will be on this page" before anything is exported. The item can also be told to ignore layer ranges entirely, which is occasionally right for an inset map that must show detail at a scale where the layer would normally be hidden — but it is worth doing deliberately rather than discovering later that one page follows different rules from the rest.

Check before exporting

An export at a scale you did not test is the usual way a scale range produces a blank map.

def visible_at(layers, scale):
    return [
        layer.name() for layer in layers
        if not layer.hasScaleBasedVisibility()
        or ((not layer.minimumScale() or scale <= layer.minimumScale())
            and (not layer.maximumScale() or scale >= layer.maximumScale()))
    ]

print(visible_at(QgsProject.instance().mapLayers().values(), 50000))

Breakdown: The comparison reads oddly because of the denominator convention: a scale is inside the range when it is less than or equal to the minimum, since a smaller denominator means more zoomed in. Running this for each scale a layout will export at turns a class of silent blank maps into a printed list you can check in a second.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7Layer and rule scale ranges present; label scaleVisibility available.
3.22 LTR3.9hasScaleBasedVisibility() accessor stable.
3.28 LTR3.9Layout map items can override layer scale ranges per item.
3.34 LTR3.12Baseline for this page.
3.40+3.12Scale-range editing exposed on symbol-level rendering passes.

Troubleshooting

  • Setting the scales did nothing. setScaleBasedVisibility(True) was never called.
  • The layer vanishes when you zoom in, not out. Minimum and maximum are swapped. Minimum is the largest denominator.
  • A gap where no rule draws. Two rule ranges do not meet. Set the far bound of each to 0 so they are unbounded on the outside.
  • Labels disappear with the features. The label settings inherited the layer range. They have their own scaleVisibility; set it separately.
  • The export is blank at one page size. That page's scale falls outside every range. Check with a visible_at() pass before exporting.
  • The range is ignored inside a layout. The map item overrides layer scale ranges. Check the item's own settings.

Conclusion

Enable the flag, remember that minimum scale is the zoomed-out limit, and use 0 for "no limit at this end". Move to per-rule ranges when a layer should change appearance rather than disappear, give labels their own range, and print the visible set for every scale you will export at before trusting the output.

Frequently Asked Questions

Does scale-based visibility make the map faster? Yes, measurably. A layer outside its range is skipped before any features are requested, so there is no query, no geometry and no rendering. It is one of the cheapest performance improvements available on a busy project.

Can a map theme record a scale range? No. Scale ranges live on the layer and apply in every theme. If two themes need different ranges, they need different layers over the same source.

What scale does a layout map item use? Its own, derived from its extent and its size on the page — not the canvas scale. That is why a layer visible on screen can be missing from an export, and why the item's scale is worth printing while debugging.

Is the scale the same in a geographic CRS? QGIS computes a scale denominator even in EPSG:4326 by converting to an approximate ground distance, but it varies with latitude. For any project that leans on scale ranges, work in a projected CRS.