Skip to main content
Graph Node maintains complete version history for all entities, enabling queries of subgraph state at any historical block. This powerful feature allows you to analyze how data evolved over time without storing snapshots.

Overview

Time-travel queries let you retrieve the exact state of entities as they existed at a specific block height. Graph Node achieves this by storing entity versions with block ranges indicating their validity period.

How It Works

Entity Versioning

For each entity, Graph Node maintains multiple versions in the database:
CREATE TABLE account (
  vid         INT8 PRIMARY KEY,
  id          TEXT NOT NULL,
  balance     NUMERIC,
  block_range INT4RANGE NOT NULL
);
vid
INT8
Unique version identifier for this specific entity version.
id
TEXT
The entity’s logical ID (can have multiple versions).
balance
NUMERIC
Entity attribute(s) - specific to your schema.
block_range
INT4RANGE
PostgreSQL range indicating blocks where this version is valid: [start, end)
  • Start (inclusive): Block where this version was created
  • End (exclusive): Block where this version was replaced or deleted
  • Current version has unlimited upper bound: [7, )

Block Range Examples

-- Entity created at block 100, never updated
block_range = [100, )

-- Entity created at block 100, updated at block 150
block_range = [100, 150)

-- Updated version, replaced at block 200
block_range = [150, 200)

-- Current version
block_range = [200, )
The exclusion constraint ensures block ranges for the same entity ID never overlap:
EXCLUDE USING GIST(id WITH =, block_range WITH &&)

Immutable Entities

Entities declared with @entity(immutable: true) use optimized storage:
type Transfer @entity(immutable: true) {
  id: ID!
  from: Bytes!
  to: Bytes!
  amount: BigInt!
}
Storage difference:
  • Uses block$ (INT) instead of block_range (INT4RANGE)
  • Stores only the creation block
  • Check becomes: block$ <= $B instead of block_range @> $B
  • Enforces UNIQUE(id) constraint
  • Enables simpler, faster BTree indexes
Immutable entities can never be updated or deleted, only created. This matches blockchain event semantics where events are permanent.

Querying Historical State

Block Height Argument

All entity queries accept an optional block argument:
query {
  token(
    id: "0x1234"
    block: { number: 15000000 }
  ) {
    id
    symbol
    totalSupply
  }
}
block.number
Int
Block height to query state at.
block.hash
Bytes
Block hash to query state at (alternative to number).

Single Entity Queries

query {
  token(id: "0x1234") {
    id
    symbol
    totalSupply
  }
}

Collection Queries

Apply time-travel to collection queries:
query {
  tokens(
    first: 100
    block: { number: 15000000 }
    where: { totalSupply_gte: "1000000000000000000" }
  ) {
    id
    symbol
    totalSupply
  }
}

Nested Entity Queries

Time-travel applies to nested entities automatically:
query {
  token(
    id: "0x1234"
    block: { number: 15000000 }
  ) {
    id
    symbol
    # Transfers at block 15000000
    transfers(first: 10) {
      id
      from
      to
      amount
    }
  }
}

SQL Translation

Here’s how Graph Node translates time-travel queries to SQL:

Query Entity at Block

query {
  account(id: "1", block: { number: 100 }) {
    id
    balance
  }
}
Translates to:
SELECT id, balance
FROM account
WHERE id = '1'
  AND block_range @> 100;

Collection Query at Block

query {
  accounts(
    first: 10
    block: { number: 100 }
    where: { balance_gte: "1000" }
  ) {
    id
    balance
  }
}
Translates to:
SELECT id, balance
FROM account
WHERE balance >= 1000
  AND block_range @> 100
ORDER BY id
LIMIT 10;

Immutable Entity Query

For immutable entities:
SELECT id, from, to, amount
FROM transfer
WHERE id = '...' AND block$ <= 100;

Entity Operations with Block Ranges

Create Entity

When creating an entity at block B:
INSERT INTO account(id, balance, block_range)
VALUES ('1', 100, '[B, )');
Creates a version valid from block B to infinity.

Update Entity

Updating at block B:
-- Close the current version
UPDATE account
SET block_range = int4range(lower(block_range), B)
WHERE id = '1' AND block_range @> 2147483647;

-- Insert new version
INSERT INTO account(id, balance, block_range)
VALUES ('1', 200, '[B, )');
Immutable entities cannot be updated - the operation is not allowed.

Delete Entity

Deleting at block B:
UPDATE account
SET block_range = int4range(lower(block_range), B)
WHERE id = '1' AND block_range @> 2147483647;
Closes the block range at B, making the entity non-existent after that block.

Rollback / Revert

Reverting to block B (removing all changes after B):
-- Delete all versions created after block B
DELETE FROM account WHERE lower(block_range) >= B;

-- Reopen the version that was current at block B
UPDATE account
SET block_range = int4range(lower(block_range), NULL)
WHERE block_range @> B;

Use Cases

Analyze how metrics evolved over time:
# Compare token state across blocks
query {
  block15M: token(id: "0x1234", block: { number: 15000000 }) {
    totalSupply
  }
  block16M: token(id: "0x1234", block: { number: 16000000 }) {
    totalSupply
  }
  current: token(id: "0x1234") {
    totalSupply
  }
}
Generate reports for specific points in time:
# All tokens as they were at year-end
query {
  tokens(
    first: 1000
    block: { number: 16500000 }  # Dec 31, 2023
    orderBy: totalSupply
    orderDirection: desc
  ) {
    id
    symbol
    totalSupply
  }
}
Verify state at specific blocks for auditing:
query {
  account(
    id: "0xuser"
    block: { number: 15500000 }
  ) {
    balance
    tokens(first: 100) {
      id
      balance
    }
  }
}
Build time-series data by querying multiple blocks:
query {
  day1: tokens(block: { number: 15000000 }, first: 10) {
    id
    totalSupply
  }
  day2: tokens(block: { number: 15100000 }, first: 10) {
    id
    totalSupply
  }
  day3: tokens(block: { number: 15200000 }, first: 10) {
    id
    totalSupply
  }
}
Investigate issues by checking state before/after specific blocks:
query {
  before: token(id: "0x1234", block: { number: 15555550 }) {
    totalSupply
  }
  after: token(id: "0x1234", block: { number: 15555560 }) {
    totalSupply
  }
}

Block Number vs Block Hash

Important: Block numbers do not uniquely identify blocks - only block hashes do.During chain reorganizations, different blocks can have the same block number. Graph Node tracks the canonical chain and resolves block numbers accordingly.For most use cases, block numbers are sufficient and more convenient. Use block hashes when:
  • Dealing with very recent blocks that might reorganize
  • Need cryptographic proof of specific block state
  • Querying during or immediately after a chain reorganization

Performance Considerations

Indexing

Graph Node creates indexes to optimize time-travel queries:
-- Exclusion index ensures no overlapping ranges
CREATE INDEX account_block_range_excl 
  ON account USING GIST(id, block_range);

-- BRIN index for time-range queries
CREATE INDEX account_block_range_brin 
  ON account USING BRIN(lower(block_range), COALESCE(upper(block_range), 2147483647), vid);

Query Performance

Queries for recent blocks are very fast:
  • Current state (no block specified): Fastest
  • Recent blocks (< 1000 blocks ago): Fast
  • PostgreSQL effectively caches recent data
Queries for distant historical blocks:
  • May scan more data to find correct versions
  • BRIN indexes help but queries are slower than recent blocks
  • Consider caching results for frequently-accessed historical states
  1. Query recent state when possible: Current state is always fastest
  2. Batch historical queries: If analyzing many blocks, batch them into single query
  3. Use specific filters: Narrow queries with where clauses
  4. Consider aggregations: Use aggregation features for time-series analytics instead of many point queries

Limitations

  1. Block hash lookups: Querying by block hash for distant blocks can be expensive
  2. Storage overhead: Maintaining version history increases storage requirements
  3. Immutable entities: Time-travel works but is trivial - each entity exists at only one block
  4. Cross-subgraph queries: Time-travel applies to single subgraph only

Example: Token Balance History

Track how a token’s total supply changed over time:
query TokenHistory {
  # Genesis
  genesis: token(
    id: "0xToken"
    block: { number: 10000000 }
  ) {
    totalSupply
  }
  
  # After 1 million blocks
  block1M: token(
    id: "0xToken"
    block: { number: 11000000 }
  ) {
    totalSupply
  }
  
  # After 2 million blocks
  block2M: token(
    id: "0xToken"
    block: { number: 12000000 }
  ) {
    totalSupply
  }
  
  # Current state
  current: token(id: "0xToken") {
    totalSupply
  }
}
Response:
{
  "data": {
    "genesis": {
      "totalSupply": "1000000000000000000000000"
    },
    "block1M": {
      "totalSupply": "1500000000000000000000000"
    },
    "block2M": {
      "totalSupply": "2000000000000000000000000"
    },
    "current": {
      "totalSupply": "2500000000000000000000000"
    }
  }
}

Best Practices

  1. Use current state by default: Only specify block when historical data is needed
  2. Cache historical queries: Results for past blocks never change - cache them
  3. Query recent blocks: More efficient than distant historical blocks
  4. Use block numbers: Simpler than block hashes for most use cases
  5. Consider aggregations: For time-series analysis, use aggregation features instead of many point queries
  6. Document block heights: When storing block numbers, document what events they represent

Next Steps

Build docs developers (and LLMs) love