Skip to main content

ORDER BY, SKIP, LIMIT — Ordering and Pagination

ORDER BY, SKIP, and LIMIT shape the final result set of a query, or the output of a WITH stage. They are evaluated after projection and aggregation.

Overview​

GoalClause
Sort ascendingORDER BY expr ASC (default)
Sort descendingORDER BY expr DESC
Sort by multiple keysORDER BY a ASC, b DESC
Skip rowsSKIP n
Limit rowsLIMIT n
Top-NORDER BY expr LIMIT n
PaginationORDER BY key SKIP $offset LIMIT $size

Syntax​

<RETURN | WITH> expr [, expr]
[ORDER BY expr [ASC | DESC] [NULLS FIRST | NULLS LAST] [, ...]]
[SKIP n]
[LIMIT n]

n must be a non-negative integer literal or a parameter that resolves to one. An integral float such as 10.0 is accepted. A negative number, a fraction, or null is an error, as in Neo4j. That includes an omitted parameter: if the host never binds $limit, then LIMIT $limit fails with LIMIT expects a non-negative integer, got null instead of returning every row.

Order a single column​

MATCH (n:User)
RETURN n.name
ORDER BY n.name ASC

ASC is the default; ORDER BY n.name is equivalent.

Direction comparison​

TypeOrdering
Int, FloatNumeric (NaN is incomparable)
StringByte-lexicographic
Booleanfalse < true
Date, DateTime, Time, LocalTime, LocalDateTimeChronological
DurationBy total length (calendar-aware)
PointNot orderable — equality only
NullSee Nulls in ordering

Multi-key ordering​

Later keys break ties in earlier keys.

MATCH (p:Person)
RETURN p
ORDER BY p.last_name ASC, p.first_name ASC, p.id ASC

Mix directions freely:

MATCH (u:User)
RETURN u
ORDER BY u.country ASC, u.age DESC

Ordering by computed expression​

You can order on anything that evaluates to a comparable value.

MATCH (p:Person)
RETURN p.name, p.age
ORDER BY p.age * -1 DESC            // youngest first

You can also order by an alias defined in the same RETURN:

MATCH (u:User)-[:WROTE]->(:Post)
RETURN u.name AS author, count(*) AS posts
ORDER BY posts DESC, author ASC

Pagination — SKIP + LIMIT​

MATCH (n:User)
RETURN n
ORDER BY n.id
SKIP  20
LIMIT 10
  • SKIP 0 / no SKIP — start at the first row.
  • LIMIT 0 — return zero rows.
  • LIMIT n without ORDER BY — the "first n" rows are undefined without a tiebreaker. Always pair LIMIT with ORDER BY when the order matters.

Parameters work identically:

MATCH (n:User)
RETURN n
ORDER BY n.id
SKIP $offset
LIMIT $page_size

Bind both parameters on every call. An unbound $page_size resolves to null, and LIMIT null is an error rather than "no limit". To make the limit optional, leave the LIMIT clause out of the query text, or pass a large explicit number from the host.

Stable pagination​

SKIP + LIMIT is offset-based and can miss / repeat rows if the underlying data changes between pages. For stable pagination, sort by an immutable key and filter by "last seen":

MATCH (n:User)
WHERE n.id > $after
RETURN n
ORDER BY n.id
LIMIT $page_size

Then use the last row's id as the next $after.

Ordering inside a pipeline​

ORDER BY and LIMIT can attach to a WITH stage. Only the surviving rows move forward.

MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
ORDER BY posts DESC
LIMIT 10
MATCH (u)-[:FOLLOWS]->(other)
RETURN u.name, count(other) AS following

This is how you express "top 10 posters, then each of their followings".

Custom sort order with CASE​

Use CASE to project a sort key that doesn't match the data's natural ordering. Typical for ordering strings by business meaning rather than alphabet:

MATCH (t:Task)
RETURN t.title, t.status
ORDER BY CASE t.status
         WHEN 'urgent' THEN 0
         WHEN 'open'   THEN 1
         WHEN 'review' THEN 2
         ELSE               3
       END, t.created_at DESC

One row per task, sorted urgent-first then newest-within-tier.

DISTINCT + ORDER BY​

DISTINCT runs before ordering. A column used to sort must either be a projected column or a deterministic expression over projected columns.

MATCH (p:Person)
RETURN DISTINCT p.city
ORDER BY p.city

UNION + ORDER BY / LIMIT​

For UNION / UNION ALL, ORDER BY and LIMIT apply to the combined result:

MATCH (n:User)    RETURN n.name AS name
UNION ALL
MATCH (n:Product) RETURN n.name AS name
ORDER BY name
LIMIT 20

Nulls in ordering​

null values sort last in ascending order and first in descending order. NULLS FIRST or NULLS LAST after the direction (GQL syntax) puts a key's nulls there whichever the direction. NULLS, FIRST and LAST are not reserved: they still work as names.

// The biggest score first, the unscored at the end
MATCH (a:Artist)
RETURN a.name, a.popularityScore
ORDER BY a.popularityScore DESC NULLS LAST, a.name

;// The unscored first, then the smallest score
MATCH (a:Artist)
RETURN a.name, a.popularityScore
ORDER BY a.popularityScore NULLS FIRST

With a RANGE index on the key, a nulls-last page can walk the index instead of sorting. Bound the key to its type and name the nulls, so the planner sees a range: the index is walked in order, then the label's nodes without the key, and the scan stops at the limit. With more keys the sort becomes a PartialSort in EXPLAIN, which orders each run of equal scores by the next key only.

MATCH (a:Artist)
WHERE a.popularityScore >= toFloat('-Infinity') OR a.popularityScore IS NULL
RETURN a.name, a.popularityScore
ORDER BY a.popularityScore DESC NULLS LAST, a.key
LIMIT 24

;// The next page, after the last row's (score, key)
MATCH (a:Artist)
WHERE (a.popularityScore <= $score AND (a.popularityScore < $score OR a.key > $key))
 OR a.popularityScore IS NULL
RETURN a.name, a.popularityScore
ORDER BY a.popularityScore DESC NULLS LAST, a.key
LIMIT 24

A page that starts among the nulls (WHERE a.popularityScore IS NULL AND a.key > $key) has no range left to walk and sorts the label's nulls.

Common patterns​

Top-N​

MATCH (u:User)-[:WROTE]->(p:Post)
RETURN u.name, count(p) AS posts
ORDER BY posts DESC
LIMIT 10

First row only​

MATCH (u:User {email: $email})
RETURN u
ORDER BY u.created ASC
LIMIT 1

Bottom-N (with tiebreaker)​

MATCH (p:Product)
RETURN p
ORDER BY p.price ASC, p.id ASC
LIMIT 5

Page N​

MATCH (n:Post)
RETURN n
ORDER BY n.published_at DESC, n.id DESC
SKIP ($page - 1) * $size
LIMIT $size

Random sample (unstable)​

MATCH (n)
RETURN n
ORDER BY math.random()
LIMIT 10

math.random() is re-evaluated per row — good for a rough sample, but don't rely on it for cryptographic randomness.

Edge cases​

Ordering by a nullable column with NULL present​

MATCH (p:Person)
RETURN p.name, p.rank
ORDER BY p.rank ASC
// Rows where p.rank IS NULL appear at the end

Ordering by a type-mixed column​

If p.score is sometimes Int and sometimes String, ordering is well-defined but unlikely to match your intent. Cast with toString or toInteger first.

LIMIT in the middle of a pipeline​

LIMIT on a WITH trims rows for downstream stages — subsequent MATCH clauses only run for the surviving rows. Use it to keep a multi-stage query bounded:

MATCH (u:User)
WITH u ORDER BY u.created DESC LIMIT 100
MATCH (u)-[:WROTE]->(p)
RETURN u.name, count(p)

SKIP larger than result count​

Returns zero rows — never an error.

Notes on performance​

  • ORDER BY sorts the full projected result set in memory. Combine with LIMIT when the input is large.
  • ORDER BY n.prop sorts the matched rows in memory, unless a RANGE index on prop can supply the order: with a range predicate on it (WHERE n.prop > $after ORDER BY n.prop LIMIT k, or the nulls form in Nulls in ordering) rows stream from the index and the scan stops after k. A tie-breaking second key keeps that, as a PartialSort over each run of equal values.
  • Pair LIMIT with a stable key (like an id) so re-running the same query yields the same rows.

See also​