logo
Published on

What is a Subgraph? A Practical Guide to The Graph (Hardhat + GraphQL)

Subgraphs turn raw blockchain events into queryable GraphQL APIs. Instead of scanning blocks yourself, you describe the data you care about (schema), where it comes from (contract events), and how to transform it (mappings). The Graph node ingests that config and keeps your dataset indexed and up to date.

If you're new to The Graph, think of a subgraph as your app's indexed view of on-chain data: you define the shape once, and the node keeps it queryable via GraphQL. In this post we’ll use a tiny Counter contract to walk through the flow: schema → manifest → mappings.

TIP

Need the exact commands? Read the repository README for step‑by‑step setup, scripts, and troubleshooting: https://github.com/keohanoi/subgraph-example#readme

Example repo used in this guide: keohanoi/subgraph-example

What is The Graph and a Subgraph? (TL;DR)

The Graph is a decentralized indexing protocol for blockchain data. A subgraph is a declarative, versioned index of on‑chain events and state that a Graph Node continuously processes and exposes as a fast GraphQL API. Instead of scanning blocks manually, you define:

  • a GraphQL schema (your API),
  • a manifest (which networks, contracts, and events to follow), and
  • mapping functions (AssemblyScript handlers that transform events into entities).

Common search terms this article covers: how to build a subgraph, The Graph tutorial, subgraph example, local subgraph with Hardhat, GraphQL for Web3, and subgraph best practices.

Subgraphs vs. rolling your own indexer

  • Faster iteration: declarative config beats custom ETL.
  • Query power: GraphQL filters, ordering, pagination out of the box.
  • Reliability: graph-node, IPFS, and Postgres handle indexing + storage.
  • Portability: deploy locally, to Hosted Service, or the decentralized Graph Network.
  • Ecosystem: community subgraphs, tooling, and schema conventions.

Prerequisites

  • Node.js v18+
  • npm or yarn
  • Docker & Docker Compose (for local graph-node, IPFS, PostgreSQL)

Quick Start (local indexing)

npm install
docker-compose up           # start graph-node + IPFS + Postgres
npm run node                # start Hardhat local chain
npm run compile             # compile smart contract
npm run deploy              # deploy + create deployments.json + test txs
npm run prepare-abis        # copy ABI into ./abis
npm run codegen             # generate types from schema + ABI
npm run build               # build subgraph
npm run create-local        # register subgraph name locally
npm run deploy-local        # deploy (version label: v0.0.1 or default)

Deployment prints the contract address and start block. Update subgraph.yaml if they differ.

Anatomy of a subgraph

  1. Schema (what you expose)
  2. Manifest (which contracts/events to index)
  3. Mappings (event → entity logic)

1) GraphQL schema (schema.graphql)

type CounterEntity @entity {
  id: ID!
  value: BigInt!
  lastUpdatedAt: BigInt!
  totalIncrements: BigInt!
  totalDecrements: BigInt!
  resets: BigInt!
}

type IncrementEvent @entity {
  id: ID!
  counter: CounterEntity!
  newValue: BigInt!
  caller: Bytes!
  timestamp: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}

type DecrementEvent @entity {
  id: ID!
  counter: CounterEntity!
  newValue: BigInt!
  caller: Bytes!
  timestamp: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}

type ResetEvent @entity {
  id: ID!
  counter: CounterEntity!
  caller: Bytes!
  timestamp: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}

2) Manifest (subgraph.yaml)

specVersion: 0.0.5
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum
    name: Counter
    network: localhost
    source:
      address: "0x5FbDB2315678afecb367f032d93F642f64180aa3"
      abi: Counter
      startBlock: 1
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      entities:
        - CounterEntity
        - IncrementEvent
        - DecrementEvent
        - ResetEvent
      abis:
        - name: Counter
          file: ./abis/Counter.json
      eventHandlers:
        - event: Incremented(indexed uint256,indexed address,uint256)
          handler: handleIncremented
        - event: Decremented(indexed uint256,indexed address,uint256)
          handler: handleDecremented
        - event: Reset(indexed address,uint256)
          handler: handleReset
      file: ./src/mapping.ts

3) Mapping (src/mapping.ts)

import { BigInt } from "@graphprotocol/graph-ts";
import { Incremented, Decremented, Reset } from "../generated/Counter/Counter";
import { CounterEntity, IncrementEvent, DecrementEvent, ResetEvent } from "../generated/schema";

const COUNTER_ID = "1";

function getOrCreateCounter(): CounterEntity {
  let counter = CounterEntity.load(COUNTER_ID);
  if (counter == null) {
    counter = new CounterEntity(COUNTER_ID);
    counter.value = BigInt.fromI32(0);
    counter.lastUpdatedAt = BigInt.fromI32(0);
    counter.totalIncrements = BigInt.fromI32(0);
    counter.totalDecrements = BigInt.fromI32(0);
    counter.resets = BigInt.fromI32(0);
  }
  return counter;
}

export function handleIncremented(event: Incremented): void {
  let counter = getOrCreateCounter();
  counter.value = event.params.newValue;
  counter.lastUpdatedAt = event.params.timestamp;
  counter.totalIncrements = counter.totalIncrements.plus(BigInt.fromI32(1));
  counter.save();
  let id = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
  let incrementEvent = new IncrementEvent(id);
  incrementEvent.counter = counter.id;
  incrementEvent.newValue = event.params.newValue;
  incrementEvent.caller = event.params.caller;
  incrementEvent.timestamp = event.params.timestamp;
  incrementEvent.blockNumber = event.block.number;
  incrementEvent.transactionHash = event.transaction.hash;
  incrementEvent.save();
}

export function handleDecremented(event: Decremented): void {
  let counter = getOrCreateCounter();
  counter.value = event.params.newValue;
  counter.lastUpdatedAt = event.params.timestamp;
  counter.totalDecrements = counter.totalDecrements.plus(BigInt.fromI32(1));
  counter.save();
  let id = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
  let decrementEvent = new DecrementEvent(id);
  decrementEvent.counter = counter.id;
  decrementEvent.newValue = event.params.newValue;
  decrementEvent.caller = event.params.caller;
  decrementEvent.timestamp = event.params.timestamp;
  decrementEvent.blockNumber = event.block.number;
  decrementEvent.transactionHash = event.transaction.hash;
  decrementEvent.save();
}

export function handleReset(event: Reset): void {
  let counter = getOrCreateCounter();
  counter.value = BigInt.fromI32(0);
  counter.lastUpdatedAt = event.params.timestamp;
  counter.resets = counter.resets.plus(BigInt.fromI32(1));
  counter.save();
  let id = event.transaction.hash.toHex() + "-" + event.logIndex.toString();
  let resetEvent = new ResetEvent(id);
  resetEvent.counter = counter.id;
  resetEvent.caller = event.params.caller;
  resetEvent.timestamp = event.params.timestamp;
  resetEvent.blockNumber = event.block.number;
  resetEvent.transactionHash = event.transaction.hash;
  resetEvent.save();
}

Contract Overview

Counter.sol functions:

  • increment() increases count and emits Incremented.
  • decrement() decreases count (guard: cannot go below zero) emits Decremented.
  • reset() owner-only, sets count to 0, emits Reset.
  • getCount() view current value.

Events carry newValue, caller, and timestamp (or just caller, timestamp for Reset) to enrich indexed entities.

Interact via Hardhat Console

npx hardhat console --network localhost
const Counter = await ethers.getContractFactory("Counter");
const counter = Counter.attach("0x5FbDB2315678afecb367f032d93F642f64180aa3"); // address from deploy
await counter.increment();
await counter.decrement();
await counter.getCount();
await counter.reset(); // owner only

Query Examples

Single entity:

{ counterEntity(id: "1") { value totalIncrements totalDecrements resets lastUpdatedAt } }

Recent increment events:

{ incrementEvents(first: 10, orderBy: timestamp, orderDirection: desc) { id newValue caller timestamp blockNumber transactionHash } }

Combined view:

{
  incrementEvents(first: 5, orderBy: timestamp, orderDirection: desc) { newValue caller timestamp }
  decrementEvents(first: 5, orderBy: timestamp, orderDirection: desc) { newValue caller timestamp }
  resetEvents(first: 5, orderBy: timestamp, orderDirection: desc) { caller timestamp }
}

Troubleshooting & Redeploy

IssueCheckFix
Graph node not startingDocker running? Ports 8000/8020/5001/5432 free?docker-compose down -v then docker-compose up
No dataManifest address/startBlock correct? Transactions made?Make txs, update subgraph.yaml, rebuild
Deployment failsHardhat node up? ABI copied?npm run prepare-abis && npm run codegen && npm run build
Need full resetStale local statenpm run remove-local && npm run create-local && npm run deploy-local

Performance & Optimization Tips

  • Start block: set startBlock close to deployment to avoid scanning irrelevant history.
  • Entity cardinality: split high‑volume events (e.g. transfers) into their own entity types to keep query payloads slim.
  • Derived fields: pre‑aggregate counters (as shown with totalIncrements) to avoid expensive client‑side iteration.
  • Reorg resilience: rely on graph-node reorg handling; avoid assumptions that event ordering is immutable until a few confirmations.
  • Versioning: tag releases (v0.0.x) when you change schema so consumers can pin.
  • Zero‑value event filtering: if a contract emits noisy events, add conditional logic in mappings to skip persisting them.
  • Batch queries: combine related entity fetches into a single GraphQL request instead of multiple round trips.

Production Hardening Checklist

  1. Pin ABI & schema in source control (no untracked changes).
  2. Add unit tests for mappings using matchstick-as (AssemblyScript testing framework).
  3. Monitor indexing status & sync speed (graph-node logs / Graph Explorer).
  4. Document schema changes (CHANGELOG + semantic version tags).
  5. Use clear entity IDs (txHash-logIndex pattern) to avoid collisions.
  6. Consider indexing only necessary events (prune manifest) to reduce load.
  7. Track mapping execution cost (keep logic minimal—no heavy loops).

Glossary

  • Graph Node: Service that ingests blockchain data + runs mappings.
  • Manifest (subgraph.yaml): Declarative configuration for sources & handlers.
  • Mapping: AssemblyScript function transforming event → entity.
  • Entity: Persisted structured record stored in the subgraph store.
  • Start Block: First block from which indexing begins—tunes performance.
  • Determinism: Mappings must be pure & deterministic for reproducible indexing.

Cleanup

docker-compose down
# Ctrl+C the Hardhat node terminal
docker-compose down -v   # optional: remove volumes

Credits & License

Content & snippets derived from the MIT-licensed example: keohanoi/subgraph-example

MIT licensed.