Short answer

A QGIS virtual layer is a layer whose contents are defined by an SQL query run live over the layers already loaded in your project. It is backed by QGIS's SQLite/SpatiaLite engine, so you get a real spatial SQL dialect — ST_Intersects, ST_Distance, joins, GROUP BY — without exporting, duplicating, or modifying any source data. The query re-evaluates when inputs change, which makes virtual layers ideal for spatial joins, attribute filters, aggregations, and quick geometry operations during analysis. The one rule that makes or breaks them: get every input into the same projected CRS first, because the SQL operates on stored coordinates.

What a virtual layer actually is

Create one via Layer ▸ Add Layer ▸ Add/Edit Virtual Layer, or the DB Manager. You write an SQL SELECT that references your loaded layers by their layer name. The result appears as a normal, queryable, stylable layer — but it stores no data of its own; it is a saved query. Because it is non-destructive and live, it is the right tool when you want to ask a question of your data rather than produce a deliverable file.

A minimal example — count how many sample points fall in each geological unit:

SELECT u.unit_name,
       count(p.fid) AS n_samples,
       u.geometry
FROM   geology_units AS u
LEFT JOIN samples AS p
       ON ST_Intersects(u.geometry, p.geometry)
GROUP BY u.unit_name, u.geometry

The geometry column is what tells QGIS the layer is spatial; include it in the SELECT (and in GROUP BY when aggregating) so the result draws on the map.

The SQL dialect and spatial functions

Virtual layers expose SQLite SQL plus SpatiaLite's spatial functions. The ones you will use constantly:

  • Predicates: ST_Intersects, ST_Within, ST_Contains, ST_Touches, ST_Crosses, ST_Disjoint.
  • Measures: ST_Distance, ST_Area, ST_Length, ST_Perimeter.
  • Constructors / transforms: ST_Buffer, ST_Centroid, ST_Union, ST_Intersection, ST_Difference, ST_ConvexHull, ST_Collect.
  • Inspection: ST_IsValid, ST_GeometryType, ST_X, ST_Y.

Plus standard SQL: JOIN, WHERE, GROUP BY, HAVING, ORDER BY, CASE, and aggregates (count, sum, avg, min, max).

Worked examples

Spatial join carrying an attribute — tag each borehole with the unit it sits in:

SELECT b.*, u.unit_name, u.lithology
FROM   boreholes AS b
JOIN   geology_units AS u
  ON   ST_Within(b.geometry, u.geometry)

Distance / proximity filter — faults within 250 m of a proposed alignment:

SELECT f.*
FROM   faults AS f, alignment AS a
WHERE  ST_Distance(f.geometry, a.geometry) < 250

(Both layers must be in a projected CRS in metres for < 250 to mean 250 metres — see below.)

On-the-fly buffer overlay — sample density inside a 1 km buffer of streams:

SELECT count(s.fid) AS samples_near_water
FROM   samples AS s, streams AS r
WHERE  ST_Intersects(s.geometry, ST_Buffer(r.geometry, 1000))

You can give the result a stable primary key and an explicit geometry declaration in the query UID/geometry fields, which avoids QGIS guessing and keeps editing and identify-tool behaviour predictable.

CRS handling — the make-or-break detail

Virtual layers compute on the coordinates stored in each layer, not on the project's display CRS. Two consequences:

  1. Mixed CRSs give wrong answers silently. If faults is in EPSG:4326 (degrees) and alignment is in EPSG:32633 (metres), ST_Distance returns a meaningless mixed number and ST_Intersects may simply find nothing. Reproject inputs to one common projected CRS before querying — either save reprojected copies, or use ST_Transform(geometry, 32633) inside the SQL on each geometry.
  2. Distances and areas need a projected CRS. ST_Area/ST_Distance in EPSG:4326 return values in square degrees / degrees, which are not usable measurements. Work in a UTM zone or national grid.

A robust pattern is to transform explicitly in the query:

SELECT ST_Area(ST_Transform(geometry, 32633)) AS area_m2, *
FROM   catchments

Performance and limitations

QGIS builds an in-memory spatial index for virtual layers, so point-in-polygon joins on moderate datasets are quick. But:

  • The query re-runs on refresh, so a heavy join over hundreds of thousands of features can lag the canvas. For repeated heavy use, materialise the result (right-click ▸ Export, or run an equivalent Processing tool) into a GeoPackage.
  • Virtual layers are read-only with respect to the computed result; you edit the source layers, not the virtual output.
  • They live inside the project file; if a source layer's name or path changes, the query breaks. Keep layer names stable.
  • For genuinely large or multi-user data, the same SQL belongs in PostGIS, which has persistent indexes (GiST), concurrency, and far better scaling.

When to use what

  • Virtual layer — fast, exploratory, non-destructive questions within a single QGIS project; spatial joins and quick aggregations you may revise repeatedly.
  • Processing algorithm (native:joinattributesbylocation, native:extractbylocation, etc.) — when you need a saved output file inside a documented, re-runnable model with processing history.
  • PostGIS — large datasets, persistent indexing, multi-user editing, and SQL you want to schedule or share outside QGIS.

Virtual layers and Processing tools complement each other: prototype the logic as a virtual-layer query, then bake the final version into a Model or PostGIS view for the deliverable.

Common pitfalls and why they happen

  • Mixed CRS inputs — the most common cause of "ST_Intersects returns nothing" or nonsensical distances; the SQL runs on raw stored coordinates.
  • Measuring in degreesST_Area/ST_Distance on EPSG:4326 data; the numbers look plausible but are degree-based and wrong.
  • Forgetting the geometry column — omit it from the SELECT and the result is a non-spatial table that will not draw.
  • No unique key — without a stable UID the identify tool and selections misbehave; declare the key field.
  • Renamed/moved source layers — breaks the saved query because it references layers by name.
  • Running huge joins live — canvas lag; materialise heavy results.

QA and validation checklist

  • All input layers reprojected to one projected CRS (or ST_Transform applied in-query) before any predicate or measurement.
  • Geometry column and a unique key declared in the virtual layer.
  • Distance/area results sanity-checked against a known reference (a measured feature).
  • Result feature count compared against an equivalent Processing tool on a sample.
  • Heavy or final queries materialised to GeoPackage, with the SQL saved alongside.

Bathyl perspective

We treat virtual layers as QGIS's interactive SQL console — perfect for interrogating geological and terrain data without spawning a dozen throwaway export files. The discipline that keeps them trustworthy is the same discipline that keeps any SQL trustworthy: control the CRS, declare the keys, validate against a known value, and promote the query to a documented Model or a PostGIS view once it becomes part of a deliverable.

Related reading

Sources