> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subgraph performance

> Make subgraphs index and query faster with immutable entities, Bytes ids, derived fields, declared eth_calls, aggregations, and pruning.

Goldsky runs standard `graph-node`, so how fast your subgraph indexes and queries is decided almost entirely by how you design it: how much work each handler does per event, and how the schema stores data. Indexing resources are not the bottleneck: a slow subgraph is almost always slow because of its design or upstream RPC performance, and the fixes below target the design.

Each recommendation shows a slow pattern and its faster replacement. Most apply at authoring time; a few (like pruning) can be added to an existing subgraph.

<Tip>
  Goldsky permanently caches the RPC calls a subgraph makes during indexing. Re-syncing the same or a similar subgraph reuses cached `eth_call` and log results, so a re-sync is much faster than the first sync. You generally don't need to engineer around RPC cost on a re-sync.
</Tip>

## Mark append-only entities immutable

By default, `graph-node` tracks block ranges and versions for every entity so it can serve historical queries and handle updates. Entities that are written once and never change (transfers, swaps, mints) don't need any of that bookkeeping. Marking them `immutable: true` skips it, so they both index and query faster.

Slower:

```graphql schema.graphql theme={"dark"}
# graph-node tracks versions and block ranges this entity never uses
type Transfer @entity(immutable: false) {
  id: Bytes!
  from: Bytes!
  to: Bytes!
  amount: BigInt!
}
```

Faster:

```graphql schema.graphql theme={"dark"}
type Transfer @entity(immutable: true) {
  id: Bytes!
  from: Bytes!
  to: Bytes!
  amount: BigInt!
}
```

Only use `immutable: true` for entities that are never updated after creation. Anything that changes over time (balances, pool reserves, positions) must stay `immutable: false`.

<Note>
  Current versions of `graph-cli` require an explicit `immutable` argument on every `@entity`. A bare `type X @entity { ... }` fails `graph codegen` and `graph build`, so write `@entity(immutable: false)` or `@entity(immutable: true)` on every type.
</Note>

## Use Bytes ids for hashes and addresses

Addresses, transaction hashes, and ids built from them are hex values. Storing them as `String` roughly doubles their storage size and makes comparisons slower. Use `Bytes` and build ids with byte concatenation instead of string concatenation.

Slower:

```graphql schema.graphql theme={"dark"}
type Transfer @entity(immutable: true) {
  id: String!   # "0xabc...-12" built with string concatenation
  from: String!
  to: String!
}
```

```typescript src/mapping.ts theme={"dark"}
let id = event.transaction.hash.toHex() + "-" + event.logIndex.toString()
let transfer = new Transfer(id)
```

Faster:

```graphql schema.graphql theme={"dark"}
type Transfer @entity(immutable: true) {
  id: Bytes!    # txHash ++ logIndex
  from: Bytes!
  to: Bytes!
}
```

```typescript src/mapping.ts theme={"dark"}
let id = event.transaction.hash.concatI32(event.logIndex.toI32())
let transfer = new Transfer(id)
```

For composite keys, concatenate the parts while staying in `Bytes`, for example `account.concat(token)`.

<Warning>
  `Bytes` ids sort by hex value, not numerically. If you need to order or paginate entities in sequence, add an explicit `BigInt` field (like `blockNumber` or a counter) and sort on that instead of the id.
</Warning>

## Derive lists instead of storing arrays

Storing a growing array on a parent entity degrades badly as it grows: every update rewrites the entire array, and very large arrays (tens of thousands of elements) can time out. Model the relationship on the child entity and derive the list on the parent with `@derivedFrom`.

Slower:

```graphql schema.graphql theme={"dark"}
type Pool @entity(immutable: false) {
  id: Bytes!
  swapIds: [Bytes!]!   # stored array, rewritten on every swap
}
```

```typescript src/mapping.ts theme={"dark"}
let swapIds = pool.swapIds
swapIds.push(swap.id)
pool.swapIds = swapIds   // rewrites the whole array, every event
pool.save()
```

Faster:

```graphql schema.graphql theme={"dark"}
type Pool @entity(immutable: false) {
  id: Bytes!
  swaps: [Swap!]! @derivedFrom(field: "pool")
}

type Swap @entity(immutable: true) {
  id: Bytes!
  pool: Pool!
}
```

```typescript src/mapping.ts theme={"dark"}
let swap = new Swap(event.transaction.hash.concatI32(event.logIndex.toI32()))
swap.pool = pool.id   // the child stores the reference; the parent stores nothing
swap.save()
```

`@derivedFrom` fields are virtual: they take no storage and are resolved at query time, so they stay fast regardless of how many children exist. For many-to-many relationships, model a join entity (for example `PoolMembership` with `pool` and `account` fields) instead of arrays on either side.

One caveat: derived fields belong in queries, not in hot mapping paths. If a handler needs an aggregate like a count or running total, keep a scalar field (for example `txCount: BigInt!`) up to date instead of reading the collection back.

## Do less work per event

Handler cost multiplies by event count, so a small amount of waste in a hot handler becomes hours of sync time. The two most common forms of waste are contract calls for data that's already in the event, and loading or saving entities the handler doesn't actually change.

Slower:

```typescript src/mapping.ts theme={"dark"}
export function handleSync(event: SyncEvent): void {
  let pool = Pool.load(event.address)
  if (pool == null) return

  // eth_call for values that never change — set once by the factory handler
  let contract = PairContract.bind(event.address)
  pool.token0 = contract.token0()
  pool.token1 = contract.token1()

  // eth_call for data that's already in the event payload
  let reserves = contract.getReserves()
  pool.reserve0 = reserves.value0
  pool.reserve1 = reserves.value1
  pool.save()
}
```

Faster:

```typescript src/mapping.ts theme={"dark"}
export function handleSync(event: SyncEvent): void {
  let pool = Pool.load(event.address)
  if (pool == null) return

  // token0/token1 were stored when the factory created the pool;
  // reserves come straight from the event parameters
  pool.reserve0 = event.params.reserve0
  pool.reserve1 = event.params.reserve1
  pool.save()
}
```

Before adding any contract call to a handler, check whether the value is already in `event.params`. For most protocols, the event payload carries everything the handler needs.

## eth\_calls: avoid, declare, or cache

An `eth_call` during indexing is a synchronous RPC round-trip, which makes it the single most expensive thing a handler can do. In order of preference:

1. **Avoid the call.** If the data is in the event payload, read it from `event.params` (see above).
2. **Declare the call.** Declared eth\_calls are listed in the manifest so `graph-node` executes them in parallel ahead of time and serves your handler from cache. This requires `specVersion: 1.2.0` or higher and only works when the call is computable from event parameters alone; it can't depend on state your mapping computed. See [Use declared eth\_calls](/subgraphs/guides/declared-eth-calls) for the full guide.
3. **Cache immutable results.** Values that never change (a token's `symbol`, `name`, `decimals`) should be fetched once, on first sight, and stored on an entity. Never re-fetch them per event.

A declared call looks like this in the manifest:

```yaml subgraph.yaml theme={"dark"}
specVersion: 1.2.0
# ...
      eventHandlers:
        - event: Swap(indexed address,indexed address,int256,int256,uint160,uint128,int24)
          handler: handleSwap
          calls:
            token0: UniswapV3Pool[event.address].token0()
            token1: UniswapV3Pool[event.address].token1()
```

And here's the caching pattern for immutable metadata:

Slower:

```typescript src/mapping.ts theme={"dark"}
export function handleTransfer(event: TransferEvent): void {
  let contract = ERC20.bind(event.address)
  let transfer = new Transfer(event.transaction.hash.concatI32(event.logIndex.toI32()))
  transfer.symbol = contract.symbol()       // eth_call on every single transfer
  transfer.decimals = contract.decimals()   // and another one
  // ...
  transfer.save()
}
```

Faster:

```typescript src/mapping.ts theme={"dark"}
function getOrCreateToken(address: Address): Token {
  let token = Token.load(address)
  if (token == null) {
    token = new Token(address)
    let contract = ERC20.bind(address)
    // fetch once, with revert-safe try_ calls and sensible defaults
    let symbol = contract.try_symbol()
    token.symbol = symbol.reverted ? "???" : symbol.value
    let decimals = contract.try_decimals()
    token.decimals = decimals.reverted ? 18 : decimals.value
    token.save()
  }
  return token
}

export function handleTransfer(event: TransferEvent): void {
  let token = getOrCreateToken(event.address)   // eth_calls only on first sight
  // ...
}
```

The `try_` prefix matters for reliability, not just speed: a contract whose `decimals()` or `symbol()` reverts is one of the most common causes of a fatally crashed subgraph. Default the value and keep indexing. You can test these revert paths locally; see [Testing subgraphs](/subgraphs/guides/testing).

If you're using instant (no-code) subgraphs, enrichment calls support the same optimization: set `declared: true` on the enrichment call (it's ignored on call handlers). See the [instant subgraph configuration reference](/subgraphs/reference/instant-subgraph).

## Let the database compute aggregates

For high-volume rolling metrics (daily volume, hourly counts), the classic pattern of loading a stats entity, adding to a total, and saving it back runs on every event. Timeseries and aggregation entities move that work into the database.

Slower:

```typescript src/mapping.ts theme={"dark"}
export function handleSwap(event: SwapEvent): void {
  let amountUSD = computeAmountUSD(event) // however you price the swap

  // load-modify-save churn on every swap
  let dayId = event.block.timestamp.toI32() / 86400
  let stats = DayStats.load(Bytes.fromI32(dayId))
  if (stats == null) {
    stats = new DayStats(Bytes.fromI32(dayId))
    stats.totalUSD = BigDecimal.zero()
  }
  stats.totalUSD = stats.totalUSD.plus(amountUSD)
  stats.save()
}
```

Faster:

```graphql schema.graphql theme={"dark"}
type Swap @entity(timeseries: true) {
  id: Int8!
  timestamp: Timestamp!
  amountUSD: BigDecimal!
}

type VolumeStats @aggregation(intervals: ["hour", "day"], source: "Swap") {
  id: Int8!
  timestamp: Timestamp!
  totalUSD: BigDecimal! @aggregate(fn: "sum", arg: "amountUSD")
}
```

Your mapping only creates the `Swap` rows; the hourly and daily totals are computed by the database. Aggregation functions include `sum`, `count`, `min`, `max`, `first`, and `last`.

<Note>
  Timeseries entities require an `Int8` id and a `Timestamp` field, and timeseries support is a newer `graph-node` feature. Confirm your manifest's `specVersion` and `apiVersion` support it, and validate with a small test deploy before building a large subgraph around it.
</Note>

## Start at the contract's deployment block

A subgraph with `startBlock: 0` scans the chain's entire history before it finds your contract's first event. Set `startBlock` to the block the contract was deployed at (or the earliest block you care about):

```yaml subgraph.yaml theme={"dark"}
dataSources:
  - kind: ethereum
    name: MyContract
    source:
      address: "0x9C8fF314C9Bc7F6e59A9d9225Fb22946427eDC03"
      abi: MyContract
      startBlock: 12985438   # deployment block, not 0
```

You can find the deployment block on a block explorer as the block of the contract-creation transaction.

## Prune history you don't need

`graph-node` keeps historical entity state so you can run time-travel queries at old blocks. If you only query current state, that history is pure overhead. Pruning limits how much is retained, which shrinks storage and speeds up queries:

```yaml subgraph.yaml theme={"dark"}
indexerHints:
  prune: auto   # or a block count, or `never`
```

`auto` keeps the minimum history needed. The trade-offs: you can't graft a new version at a pruned block, and time-travel queries below the pruned range won't work. Use `never` if you need full history or plan to graft from old blocks.

## Grafting won't make indexing faster

Grafting starts a new version from an existing version's already-indexed data at a chosen block, and Goldsky fully supports it. But if your goal is "make this subgraph faster without re-indexing from scratch," be clear about what grafting does:

* **Grafting only skips re-processing old blocks.** The remaining blocks index faster only if the new code does less work per event.
* **The biggest speedups break graft compatibility.** Making entities immutable, changing ids to `Bytes`, or restructuring relationships all change the schema, and you can't graft across a schema change.
* **Graft-safe changes** are manifest-level and mapping-internal only: declaring eth\_calls, trimming work inside handlers, removing an unused handler. These keep the schema identical, but they often don't touch a design-bound bottleneck.

If the changes that would actually help require schema changes, the realistic option is to rebuild leaner and re-sync from scratch: keep the live-state entities and raw event logs (immutable, `Bytes` ids, `@derivedFrom` collections), and drop expensive machinery you don't query: per-event USD pricing, unused token metadata, hand-rolled day/hour aggregates, and per-event eth\_calls. Deploy the lean version under a new name, leave the original running, and cut your app over once the new version has synced. Because each block does far less work, this routinely turns a multi-day sync into hours, and Goldsky's permanent RPC cache means the re-sync reuses previously fetched call results.

## Spot slow indexing

Watch a deployed subgraph's indexing progress on its [dashboard](https://app.goldsky.com), or check status and error logs from the CLI:

```shell theme={"dark"}
goldsky subgraph list
```

If a subgraph stops making progress entirely, Goldsky detects it, emails your team, and may automatically pause it; see [Stalled subgraph detection](/subgraphs/stalled-subgraphs) for how that works and how to recover. If progress is steady but slow, work through the checklist below: the cause is almost always per-event work.

<Tip>
  Every deployed subgraph version is billed separately (worker fee plus entity storage). Deleting old versions you no longer query is the cheapest, highest-impact optimization you can make.
</Tip>

## Checklist

* Append-only event entities are `@entity(immutable: true)` with `Bytes` ids
* Every one-to-many relationship uses `@derivedFrom`, no stored arrays
* No per-event eth\_calls: read from `event.params`, declare calls (`specVersion: 1.2.0`+), or cache immutable metadata
* Timeseries and aggregations for rolling metrics instead of load-modify-save totals
* `startBlock` set to the contract's deployment block
* Pruning configured if you don't need deep history
* Old versions deleted


## Related topics

- [Use declared eth_calls to increase indexing performance](/subgraphs/guides/declared-eth-calls.md)
- [Testing subgraphs](/subgraphs/guides/testing.md)
- [Create low-code subgraphs](/subgraphs/guides/create-a-low-code-subgraph.md)
- [Index onchain data with Subgraphs](/subgraphs/introduction.md)
- [Migrate from TheGraph](/subgraphs/migrate-from-the-graph.md)
