Writing Efficient Queries

Introduction

Welcome back to the Indexes and Performance course! We have reached the final lesson of this unit, where we bring together everything we have learned about indexes and performance optimization. In previous lessons, we created property indexes and explored compound indexes. Now, we will focus on writing queries that make the most of these optimizations.

Even with perfect indexes in place, the way we structure our queries can dramatically impact performance. In this lesson, we will examine practical patterns for writing efficient queries, understand when to filter data, and learn techniques that help Neo4j's query planner do its best work.

Query Efficiency: Why Structure Matters

Before diving into specific patterns, let's understand why query structure matters even when indexes exist. Neo4j's query planner makes decisions based on how we write our queries, including when to use indexes, when to filter data, and how to order operations. Two queries that produce the same result can have very different performance characteristics.

The key principle is reducing cardinality early: we want to minimize the number of rows (or records) flowing through our query pipeline as soon as possible. This means filtering, limiting, and using indexes before performing expensive operations like traversing relationships, sorting large result sets, or aggregating data.

Property Matching: Index-Friendly Patterns

Let's start by examining two common ways to filter nodes by property values. Both approaches work, but one creates a more direct path to using our indexes:

// Good: uses index directly
MATCH (user:User {name: 'Alice'})
RETURN user

This inline property matching syntax tells Neo4j exactly what we are looking for. When an index exists on :User(name), the query planner can immediately recognize this as an index seek operation. The syntax is concise and makes our intent clear: find the specific user with this name.

Here is what this query returns:

╒═══════════════════════════════════════════════════════════════════════╕
│ user                                                                  │
╞═══════════════════════════════════════════════════════════════════════╡
│ (:User {name: "Alice", city: "New York", age: 28, email: "alice@...})│
╘═══════════════════════════════════════════════════════════════════════╛

Alternative Filtering Approach

Now, let's look at the alternative pattern that achieves the same result:

// Equivalent syntax: results in the same execution plan
MATCH (user:User)
WHERE user.name = 'Alice'
RETURN user

While this query produces identical results, it follows a different logical path. We first match all users with the label :User and then filter them by name. In practice, Neo4j's query planner often optimizes this to use the same index, but the inline syntax from the previous example communicates our intent more directly and leaves less room for the planner to misinterpret our needs.

For simple equality predicates like this, both forms typically perform similarly. However, the inline pattern is generally preferred for its clarity and directness, especially when matching on a single property that has an index.

The Power of Early Filtering

When we combine filtering with sorting and limiting results, the order of operations becomes crucial. Let's examine an efficient pattern:

// Good: limits early
MATCH (user:User)
WHERE user.age > 30
RETURN user.name
ORDER BY user.age DESC
LIMIT 10

This query structure exemplifies early filtering. We filter users by age first with the WHERE clause, reducing our dataset immediately. Then, we sort and limit the results. This approach minimizes the amount of data that flows through the sorting operation, which can be expensive for large datasets.

Notice how the WHERE clause appears right after the MATCH and before any sorting or limiting. This positioning allows Neo4j to apply the filter as early as possible in the query execution.

Here is the output:

╒═══════════╕
│ user.name │
╞═══════════╡
│ "Cara"    │
│ "Iris"    │
│ "Kelly"   │
│ "Charlie" │
│ "Xavier"  │
│ "Paul"    │
│ "Tina"    │
│ "Kate"    │
│ "Harry"   │
│ "Amy"     │
╘═══════════╛

The Cost of Late Filtering

Let's contrast the previous example with a less efficient approach:

// Less efficient: limits late
MATCH (user:User)
WITH user ORDER BY user.age DESC
WHERE user.age > 30
RETURN user.name
LIMIT 10

This query produces a similar final result, but the execution path is more costly. Here, we sort all users by age before filtering them. This means Neo4j must sort the entire dataset, including users with age 30 or below, only to filter them out afterward.

The WITH clause creates a pipeline stage that forces the sort to happen before the filter. This can cause Neo4j to process many more rows than necessary. In a database with thousands or millions of users, the difference in performance between early and late filtering can be substantial.

Here is a comparison of both outputs:

// Late filtering output:
╒═══════════╕
│ user.name │
╞═══════════╡
│ "Iris"    │
│ "Cara"    │
│ "Charlie" │
│ "Paul"    │
│ "Xavier"  │
│ "Kelly"   │
│ "Kate"    │
│ "Tina"    │
│ "Harry"   │
│ "Grace"   │
╘═══════════╛

// Early filtering output:
╒═══════════╕
│ user.name │
╞═══════════╡
│ "Cara"    │
│ "Iris"    │
│ "Kelly"   │
│ "Charlie" │
│ "Xavier"  │
│ "Paul"    │
│ "Tina"    │
│ "Kate"    │
│ "Harry"   │
│ "Amy"     │
╘═══════════╛

Both queries return 10 users over age 30, ordered by age descending. You may notice slight differences in the specific users returned when multiple users share the same age value—this is because the sort order for tied values is not guaranteed to be stable. The key point is that both queries accomplish the same goal, but the early filtering version processes far fewer records during the sorting operation.

Understanding the Difference

The fundamental difference between these two patterns lies in when we reduce the dataset size. In the efficient version, we filter first and then sort only the qualifying records. In the less efficient version, we sort everything and then filter.

Consider a scenario with 100,000 users where only 20,000 are over 30. The efficient query sorts 20,000 records, while the inefficient one sorts all 100,000. Additionally, when we combine ORDER BY and LIMIT, Neo4j can use a Top-N optimization that maintains only the top 10 records in memory, further reducing resource usage.

Best Practices for Query Optimization

Based on what we have explored, here are essential practices to keep in mind when writing queries:

  • Use inline property matching for single, indexed properties when filtering nodes.
  • Apply WHERE clauses early in your query pipeline, before ORDER BY or aggregations.
  • Combine ORDER BY and LIMIT together without intermediate WITH clauses when possible.
  • Start from the most selective node when traversing relationships, using indexed properties as anchors.
  • Include labels and relationship types in your MATCH patterns rather than filtering them later.
  • Profile your queries regularly using the PROFILE command to verify that indexes are being used.

These practices help Neo4j's query planner make optimal decisions and ensure that our queries scale well as data grows.

Conclusion and Next Steps

We have now completed our exploration of query optimization techniques in Neo4j. We learned how to structure queries that leverage indexes effectively, apply filters at the right time, and minimize the amount of data flowing through expensive operations. Remember that even with perfect indexes, query structure determines whether those indexes are actually used.

The patterns we discussed today represent common scenarios, but every database and query has unique characteristics. The most important skill is knowing how to analyze and improve query performance using the tools Neo4j provides. Now it's time to put these concepts into practice and sharpen your optimization skills through hands-on exercises!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal