Learn / Tutorials / First Steps with EventSourcingDB / Running EventQL Queries
Step 3 of 8

Running EventQL Queries

This guide shows how to query events using EventQL – the query language built into EventSourcingDB. You'll learn how to run your first queries and understand when using EventQL makes sense.

Why Use EventQL?#

When working with EventSourcingDB, you typically read events by subject. That works well when you're interested in the full history of a single entity – like a book or a reader.

But sometimes you want to ask different questions:

  • What are all the events in the system?
  • How many times was a book borrowed?
  • Which event types have occurred so far?

To answer these questions, EventQL offers a flexible way to filter, sort, and aggregate events on the server side – using a syntax that is inspired by SQL but tailored for event data. Unlike SQL, EventQL works with nested structures, streams results as they are processed, and reflects the temporal nature of event-based systems.

Running a Basic Query#

To run a query, send a QUERY request to /api/v2/data/run-eventql-query and pass the query as a string in query. For example, the following query returns all events in the database:

FROM e IN events
PROJECT INTO e

You can execute this query using curl:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"query\": \"FROM e IN events PROJECT INTO e\"
  }" \
  http://localhost:3000/api/v2/data/run-eventql-query

This will return all events, one per line, in the order they were written, followed by an end item. What each line looks like is explained in Understanding the Result.

Listing the Most Recent Events#

If you're interested in the most recent events – regardless of their type or subject – you can sort them in descending order and limit the result:

FROM e IN events
ORDER BY e.id DESC
TOP 100
PROJECT INTO e

This query returns the 100 latest events, the newest first. Ordering by e.id reads the events in the order in which they are stored, here backwards, so the query stops after 100 events, however many the store holds. Ordering by e.time would return the same events, but it would read every event to sort them, see Reading Only Part of the Events.

Filtering by Type#

You can filter events by type to narrow down your results. For example, to find all events where a book was acquired:

FROM e IN events
WHERE e.type == "io.eventsourcingdb.library.book-acquired"
PROJECT INTO { id: e.id, title: e.data.title }

This returns each event's ID and the acquired book's title.

Filtering by Subject#

You can also filter events for a specific subject. The result is similar to what you'd get from reading the events of that subject, but you have full control over the output format:

FROM e IN events
WHERE e.subject == "/books/42"
PROJECT INTO e

This returns all events that belong to the book with ID 42.

Counting Events#

If you're only interested in how many events match a condition, you can use the COUNT() function:

FROM e IN events
WHERE e.type == "io.eventsourcingdb.library.book-borrowed"
PROJECT INTO { total: COUNT() }

This query tells you how often books have been borrowed.

Grouping by Type#

To count how many events exist for each event type, use grouping and aggregation:

FROM e IN events
GROUP BY e.type
PROJECT INTO { type: UNIQUE(e.type), count: COUNT() }

This groups all events by type and counts how many times each type occurred.

Understanding the Result#

The result is returned as a stream in NDJSON format – one line per result. Each result is an item of the type row, whose data holds the projected value as value, exactly as specified in your PROJECT INTO clause. After the last row, the stream ends with an end item. If you've followed the steps in Writing Events, the query from Filtering by Type returns this stream:

{"type":"row","data":{"value":{"id":"0","title":"2001 – A Space Odyssey"}}}
{"type":"end","data":{}}

The keys of an object come in alphabetical order, not in the order of your PROJECT INTO clause. Apart from its items being rows rather than events, the stream works like the one you get when reading events: if a query takes a while, it sends a heartbeat item whenever it has sent nothing for a second, which you can skip.

A query sees the database as of the moment it started: events that are written while it runs are not part of its result, so every query works on one consistent state.

When a Query Goes Wrong#

If a query contains a mistake, the stream holds an error item with a code, followed by the end item, and the status is 200 OK all the same. A query that does not parse, such as one with PROJEKT instead of PROJECT, is refused with query-malformed before any row. Where the position of the mistake is known, the details name it as start and end, each with a line and a column, counting from 1:

{"type":"error","data":{"code":"https://docs.eventsourcingdb.io/errors/query-malformed","description":"query is malformed at line 1, column 18: expected next token to be one of ['FROM' 'GROUP' 'ORDER' 'PROJECT' 'SKIP' 'TOP' 'WHERE'], got PROJEKT instead","details":{"end":{"column":25,"line":1},"start":{"column":18,"line":1}}}}
{"type":"end","data":{}}

A query that parses but fails while it runs, for example because a function gets a value it does not take, is reported with query-unevaluable, again with its position where it is known. Such a mistake may show only at some of the events, so the error can come after rows have been sent – and those rows are then not the whole result.

Your Turn#

Try running a few queries on the events you've already written:

  • Show all events for the system.
  • Display the 10 most recent events.
  • Filter all book borrowed events for a specific book.
  • Count how many events exist for a reader.
  • Group events by type and show the number of occurrences.

To explore more advanced features, including subqueries, functions, and type conversions, see the EventQL reference.