Learn / Tutorials / First Steps with EventSourcingDB / Reading Events
Step 2 of 8

Reading Events

This guide shows how to read events from EventSourcingDB using its HTTP API. You'll learn how to read either all or a desired subset of the previously stored events.

Reading From a Single Subject#

Once you've written events, the next step is typically to read them back. The simplest approach is to read the events for a single subject. All you need is the subject name, e.g. /books/42.

To read events, send an HTTP request using QUERY to the /api/v2/data/read-events route.

Why QUERY for Reading?

You might wonder why QUERY is used for reading instead of GET. A read passes its parameters in the request body, which is much more convenient than encoding everything in the query string – especially when parameters become complex. GET has no body, so EventSourcingDB uses QUERY, the method that RFC 10008 defines for a request that only reads, but carries a body.

For this reason, the routes of EventSourcingDB that read data use QUERY, and those that do something, such as writing events, use POST. With curl, you send a QUERY request with -X QUERY, just like any other method.

To read all events for the subject /books/42, run the following command:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/books/42\"
  }" \
  http://localhost:3000/api/v2/data/read-events

Without further options, EventSourcingDB reads the events of exactly this subject, not those of the subjects below it.

If everything worked as expected, the server will respond with HTTP status code 200 OK:

HTTP/1.1 200 OK

The response body will then contain all stored events for that subject, each as an event item, followed by an end item. If you've followed the steps in Writing Events, you should see the following two events, apart from their times and hashes:

{
  "type": "event",
  "data": {
    "specversion": "1.0",
    "id": "0",
    "source": "https://library.eventsourcingdb.io",
    "subject": "/books/42",
    "type": "io.eventsourcingdb.library.book-acquired",
    "time": "2026-10-25T10:02:17.635402918Z",
    "predecessorhash": "0000000000000000000000000000000000000000000000000000000000000000",
    "datacontenttype": "application/json",
    "data": {
      "author": "Arthur C. Clarke",
      "isbn": "978-0756906788",
      "title": "2001 – A Space Odyssey"
    },
    "traceparent": null,
    "tracestate": null,
    "hash": "esdb:hash:v2:83610ab420e9342366432834e55c6d2c7dfc9a159a96868562a462fe6139ae01",
    "signature": null
  }
}
{
  "type": "event",
  "data": {
    "specversion": "1.0",
    "id": "1",
    "source": "https://library.eventsourcingdb.io",
    "subject": "/books/42",
    "type": "io.eventsourcingdb.library.book-borrowed",
    "time": "2026-10-25T10:05:41.20817935Z",
    "predecessorhash": "esdb:hash:v2:83610ab420e9342366432834e55c6d2c7dfc9a159a96868562a462fe6139ae01",
    "datacontenttype": "application/json",
    "data": {
      "borrowedBy": "/readers/23",
      "borrowedUntil": "2025-11-17"
    },
    "traceparent": null,
    "tracestate": null,
    "hash": "esdb:hash:v2:85f02738ab78cfcee2a3880e3e9597e080deb2f0f1e602d9118942858d58bec1",
    "signature": null
  }
}
{
  "type": "end",
  "data": {}
}

In reality, the output will look slightly different – the items are returned as compact NDJSON, with each item occupying a single line. The end item comes last and tells you that the stream is complete: a stream that breaks off without it, for example because the connection dropped, has not delivered everything.

Why NDJSON Instead of JSON?

Note that the response is formatted as NDJSON, which is not valid JSON.

According to the JSON standard, only one top-level element is allowed. If multiple items need to be returned – as is the case here – they would have to be wrapped in an array. However, this would be impractical for stream-based processing, since a client would need to buffer the entire array before it could begin parsing.

That's why EventSourcingDB uses the NDJSON format whenever multiple items need to be returned: each item appears on its own line, separated by newline characters. This allows for efficient streaming and incremental parsing.

If you specify a non-existent subject, you won't get an error. Instead, you will get an HTTP status code 200 OK as well, but the stream holds nothing except its end item.

A read returns the database as of the moment it started: events that are written while it runs are not part of it. To receive new events as they are written, observe them instead, as Observing Events shows.

If something stands in the way while the read runs, for example because the server begins to shut down, the stream carries an error item in place of the remaining events, followed by the end item. The overview of the errors lists every error and explains how it arrives.

Reading From Multiple Subjects#

Suppose you want to read not just the events for a single book, but all events for all books – in their original (interleaved) order. To do that, specify the parent subject /books.

However, just changing the subject alone won't work, because no events have been written to /books itself.

To include the sub-subjects (e.g., /books/42), you must explicitly set the recursive option to true:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/books\",
    \"options\": {
      \"recursive\": true
    }
  }" \
  http://localhost:3000/api/v2/data/read-events

Reading All Events#

You can use the same mechanism to read all events stored in EventSourcingDB, in chronological order. Simply use the root subject / and ensure to set recursive to true, because all subjects form a hierarchy starting from /:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/\",
    \"options\": {
      \"recursive\": true
    }
  }" \
  http://localhost:3000/api/v2/data/read-events

Lots of Events

Before doing this in a production environment, keep in mind that it may result in a large number of events being read.

From a database perspective, this is not a problem – EventSourcingDB is optimized for reading large event streams. But you may generate a significant amount of network traffic. Use this option responsibly.

Fine-Tuning Reads#

Besides recursive, the options take several more parameters, all of them optional, that you can use to control how events are read.

Reading in Reverse Order#

You can control the order in which events are read using the order parameter. The default is chronological. However, sometimes you may want to read in reverse – starting with the most recent events. This can be useful e.g. when displaying a history view where the most recent changes should be shown first.

To do this, set order to antichronological:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/books/42\",
    \"options\": {
      \"order\": \"antichronological\"
    }
  }" \
  http://localhost:3000/api/v2/data/read-events

Reading a Specific Range#

By default, all events for the given subject are read. But sometimes you may want to restrict the range – for example, when implementing pagination.

You can use the lowerBound and upperBound parameters (individually or together) to limit the result. Both bounds can be configured to be inclusive or exclusive:

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/books/42\",
    \"options\": {
      \"lowerBound\": {
        \"id\": \"2001\",
        \"type\": \"inclusive\"
      },
      \"upperBound\": {
        \"id\": \"3001\",
        \"type\": \"exclusive\"
      }
    }
  }" \
  http://localhost:3000/api/v2/data/read-events

IDs Are Strings

Even if IDs look numeric, you must pass them as strings. This is required by the CloudEvents specification, which defines event IDs to be strings. Write them as EventSourcingDB does, as digits without a sign or a leading zero, such as "2001": from version 1.3 on, an ID such as "02001" is refused.

A range that no event could ever match is refused rather than answered with an empty stream: an exclusive lowerBound at the largest possible event ID, an exclusive upperBound of 0, or bounds that leave no ID between them, such as a lowerBound above the upperBound. EventSourcingDB then answers with the error range-unsatisfiable. A range that merely holds no events yet, such as one beyond the latest event, is not an error: its stream holds nothing but the end item.

Reading From the Last Event of a Given Type#

Sometimes it's useful to read all events that occurred after a certain type of event – for example, everything that happened since a book was last borrowed.

You can achieve this using the fromLatestEvent option (which starts reading from the most recent event of a given type):

curl \
  -i \
  -X QUERY \
  -H "authorization: Bearer <API_TOKEN>" \
  -H "content-type: application/json" \
  -d "{
    \"subject\": \"/books/42\",
    \"options\": {
      \"fromLatestEvent\": {
        \"subject\": \"/books/42\",
        \"type\": \"io.eventsourcingdb.library.book-borrowed\",
        \"ifEventIsMissing\": \"read-everything\"
      }
    }
  }" \
  http://localhost:3000/api/v2/data/read-events

Reading from the latest event is especially useful for performance optimization when combined with Snapshots.

A key question here is: what happens if no matching event of that type is found? That behavior is controlled using the ifEventIsMissing option:

  • If set to read-everything, all events for the given subject will be read.
  • If set to read-nothing, the stream holds nothing but its end item.

Incompatible Parameters

You cannot combine fromLatestEvent with lowerBound.

Also, reading from the latest event of a given type is only supported in chronological order – not antichronological.

EventSourcingDB refuses either combination with selection-conflicting. You can combine fromLatestEvent with an upperBound, but if the latest event of the given type lies above the upperBound, no event is left to read, and the read is refused with range-unsatisfiable.

Your Turn#

Try writing some events about books (such as book acquired and book borrowed) and readers (such as reader applied and reader accepted). Then read these events in different ways.

Here are some ideas:

  • Read all events for a specific book in anti-chronological order.
  • Read all events for all books and all readers using a single request.
  • Read all events for a specific book since it was last borrowed.

You can also extend the model – for example, by introducing another sub-subject for book pages, such as /books/42/pages/23, to record events like a page being damaged.

Then try the following:

  • Read all events for a book including its pages.
  • Read all events for a specific book and its pages since the book was last borrowed.