Querying with SQL++ (SELECT & joins)
|
This section documents the current Couchbase Server 7.6.x line as published at the Couchbase Server documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Enterprise-Edition-only Analytics, auditing, encryption at rest, the Backup service and rack-zone awareness, and Capella-only App Services and Columnar) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Couchbase iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
SQL++ (formerly N1QL) is Couchbase’s query language: SQL’s SELECT generalised to schemaless JSON — nested objects, arrays, and values that may be NULL, MISSING, or a different type in every document.
Statements run on the Query service, a stateless tier that parses and plans each request and reads data
through the Index and Data services.
The Query service and how you run a statement
A SQL++ statement targets a keyspace — the bucket / scope / collection path, such as the airline
collection in the inventory scope of the travel-sample bucket. Any name containing a hyphen is
wrapped in backticks, as in the examples below. You submit a statement in one of several ways:
# cbq -- the interactive query shell shipped with the server
$ cbq -engine=http://localhost:8093 -u Administrator -p password
cbq> SELECT name FROM `travel-sample`.inventory.airline LIMIT 3;
# the /query/service REST endpoint (what every client ultimately calls)
$ curl -s http://localhost:8093/query/service \
-u Administrator:password \
--data-urlencode 'statement=SELECT name FROM `travel-sample`.inventory.airline LIMIT 3'
The Query Workbench in the web console runs the same statements with a result grid and a visual plan. From
an SDK you call cluster.query(…) for a statement that names its keyspaces in full, or
scope.query(…) to resolve unqualified collection names against a scope:
// Java SDK -- named parameters, never string-concatenated input
QueryResult r = cluster.query(
"SELECT a.name FROM `travel-sample`.inventory.airline a WHERE a.country = $country",
queryOptions().parameters(JsonObject.create().put("country", "France")));
// scope-level: the unqualified `airline` resolves inside inventory
Scope inventory = cluster.bucket("travel-sample").scope("inventory");
inventory.query("SELECT name FROM airline WHERE country = $1",
queryOptions().parameters(JsonArray.from("France")));
USE KEYS turns a query into a direct key-value lookup with no index scan when the document keys are
already known:
SELECT * FROM `travel-sample`.inventory.airline USE KEYS ["airline_10", "airline_10748"];
SQL generalises SQL to JSON in a few ways (https://docs.couchbase.com/server/current/n1ql/n1ql-language-reference/index.html[SQL language reference]):
-
Nested-path navigation.
geo.altandschedule[0].dayreach into objects and arrays with dotted and subscripted paths. -
MISSINGvs.NULL. An absent field evaluates toMISSING, distinct from an explicitNULL, and both differ from a present value.IS MISSING,IS NULLandIS VALUEDtest the three states;IFMISSING()andIFMISSINGORNULL()collapse them. -
Heterogeneous results. Rows in one result need not share a shape; a projection can return an object, a bare scalar (
SELECT RAW), or a nested array.
For the relational starting point this builds on, see SQL Queries (SELECT).
The SELECT clause set
The clause set and its evaluation order mirror SQL (SELECT syntax):
SELECT r.airline, COUNT(*) AS routes, AVG(r.distance) AS avg_km FROM `travel-sample`.inventory.route AS r WHERE r.stops = 0 GROUP BY r.airline HAVING COUNT(*) > 100 ORDER BY routes DESC LIMIT 10 OFFSET 0;
SELECT RAW (synonym SELECT VALUE) unwraps the single projected expression, so the result is an array
of bare values instead of an array of one-field objects — ideal for feeding an IN list or an SDK
List<String>:
SELECT RAW a.name FROM `travel-sample`.inventory.airline AS a WHERE a.country = "France"; -- ["Air France", "Aigle Azur", ...]
Avoiding large offsets
LIMIT/OFFSET still requires the Query service to scan and discard the first OFFSET rows before it can
return the requested page, so deep pagination gets progressively slower. Paginate with a seek query on a
unique indexed field instead, carrying the last seen key forward — no OFFSET at all:
-- page 1 SELECT r.id, r.airline, r.distance FROM `travel-sample`.inventory.route AS r ORDER BY r.id LIMIT 20; -- next page: start after the last id seen, no OFFSET SELECT r.id, r.airline, r.distance FROM `travel-sample`.inventory.route AS r WHERE r.id > $lastKey ORDER BY r.id LIMIT 20;
See Pagination: Offset vs. Keyset for why this matters and how the same pattern applies across SQL, MongoDB, Solr, Elasticsearch, GraphQL and Spring Data.
Nested paths and array subscripts appear anywhere an expression is allowed:
SELECT h.name, h.geo.lat, h.geo.lon, h.reviews[0].ratings.Overall AS first_rating FROM `travel-sample`.inventory.hotel AS h WHERE h.geo.alt IS VALUED;
UNNEST flattens an array-valued field into one row per element, joining each element back to its
parent row:
SELECT r.sourceairport, s.day, s.flight FROM `travel-sample`.inventory.route AS r UNNEST r.schedule AS s WHERE r.airline = "AF" LIMIT 5;
NEST is the inverse: it gathers matching documents from another keyspace into an array field on each
left-hand row
(NEST clause):
SELECT a.name, ARRAY_LENGTH(routes) AS route_count FROM `travel-sample`.inventory.airline AS a NEST `travel-sample`.inventory.route AS routes ON routes.airlineid = META(a).id LIMIT 5;
Joins
SQL++ offers three join styles (JOIN clause).
Lookup join — ON KEYS. The right-hand keyspace is reached by document key computed from the left
row. Fast, and no index on the right side is needed, but the join key must be a document key:
SELECT r.sourceairport, r.destinationairport, a.name FROM `travel-sample`.inventory.route AS r JOIN `travel-sample`.inventory.airline AS a ON KEYS r.airlineid LIMIT 5;
ANSI join. Any boolean predicate joins the two sides and the optimizer may use an index on either. LEFT
[OUTER] JOIN keeps unmatched left rows with MISSING right-hand fields:
SELECT r.sourceairport, a.name AS airline, ap.airportname FROM `travel-sample`.inventory.route AS r JOIN `travel-sample`.inventory.airline AS a ON r.airlineid = META(a).id LEFT JOIN `travel-sample`.inventory.airport AS ap ON r.sourceairport = ap.faa WHERE a.country = "United States" LIMIT 10;
Index join. The reverse direction of a lookup join — follow a key held on the right side back to the left — made efficient by an index on the right-hand join field:
CREATE INDEX idx_route_airlineid ON `travel-sample`.inventory.route(airlineid); SELECT a.name, r.destinationairport FROM `travel-sample`.inventory.airline AS a JOIN `travel-sample`.inventory.route AS r ON META(a).id = r.airlineid WHERE a.icao = "SWA";
A join can target the same keyspace (a self-join for hierarchy or pairing) or another one, in the same scope or across scopes. For the relational treatment of joins see SQL Relations; for the pipeline-stage equivalent see MongoDB Aggregation ($lookup).
WITH, subqueries and collection operators
WITH binds a named result once and reuses it, the same as a SQL common table expression
(WITH clause):
WITH big_countries AS ( SELECT RAW country FROM `travel-sample`.inventory.airport GROUP BY country HAVING COUNT(*) > 200 ) SELECT a.name, a.country FROM `travel-sample`.inventory.airline AS a WHERE a.country IN big_countries;
A recursive CTE (Couchbase Server 7.6) walks a hierarchy: a seed term unioned with a recursive term that references the CTE name.
WITH RECURSIVE chain AS (
SELECT e.id, e.name, e.managerId, 1 AS depth
FROM main.hr.employee AS e
WHERE e.id = "e-1"
UNION
SELECT e.id, e.name, e.managerId, chain.depth + 1
FROM chain
JOIN main.hr.employee AS e ON e.managerId = chain.id
)
-- an OPTIONS { "levels": 10 } clause on the CTE caps recursion depth
SELECT name, depth FROM chain ORDER BY depth;
Subqueries appear in FROM (a derived keyspace, which needs an explicit alias), in WHERE, and in
the projection. LET introduces a named expression usable in SELECT and WHERE; LETTING
does the same after GROUP BY for expressions over aggregates:
SELECT p.name, band FROM main.catalog.product AS p LET band = CASE WHEN p.price > 200 THEN "high" ELSE "standard" END WHERE band = "high";
SELECT r.country, num_routes, num_routes >= 10000 AS major FROM `travel-sample`.inventory.route AS r GROUP BY r.country LETTING num_routes = COUNT(*) HAVING num_routes > 1000 ORDER BY num_routes DESC;
Collection operators test or transform arrays inline without UNNEST:
-- ANY / EVERY: existential and universal quantification over an array
SELECT META().id
FROM `travel-sample`.inventory.route AS r
WHERE ANY s IN r.schedule SATISFIES s.day = 1 END;
-- ARRAY: a comprehension that projects a new array
SELECT r.destinationairport,
ARRAY s.flight FOR s IN r.schedule WHEN s.day = 1 END AS monday_flights
FROM `travel-sample`.inventory.route AS r
LIMIT 5;
-- FIRST: the first matching element
SELECT FIRST s.flight FOR s IN r.schedule WHEN s.utc > "18:00" END AS evening
FROM `travel-sample`.inventory.route AS r
LIMIT 5;
How a SQL++ query executes
The Query service turns each statement into a pipeline of operators: parse and semantic check, optimize (pick a Global Secondary Index per keyspace and a join order), scan the index for candidate keys, fetch the full documents from the Data service, then apply joins, residual filters, grouping, ordering and the final projection.
A statement with no usable secondary index falls back to a primary scan of every key in the keyspace, so
covering the WHERE and JOIN predicates with indexes is what keeps a query fast — see
Indexes & views. Continue with
SQL++ data modification, functions &
transactions.