JSON Graph Data: Adjacency Lists, Trees, and D3.js Format

Last updated:

JSON represents graph data in three main formats — adjacency list {nodes: [], edges: []}, adjacency matrix as a 2D JSON array, and nested tree structure with recursive children arrays. D3.js uses the {nodes: [{id, ...},...], links: [{source, target, value},...]} format for force-directed graphs; GeoJSON FeatureCollection with Feature objects and geometry.coordinates arrays represents geographic graph data. This guide covers JSON adjacency list design, tree serialization and traversal, D3.js graph format, GeoJSON structure, Cytoscape.js JSON, and converting between graph representations. Use Jsonic's JSON formatter to validate and explore any graph JSON structure while building your data pipeline.

JSON Adjacency List Format

The adjacency list is the standard JSON format for representing general graphs. The top-level object holds two arrays: nodes (one object per vertex) and edges (one object per connection). Each node carries an id field — typically a string — plus any metadata your application needs. Each edge carries source and target fields referencing node ids.

For directed graphs, the source to target direction is semantically significant — include an edge in only one direction unless the connection is bidirectional. For undirected graphs, include one edge per pair; your traversal code treats each edge as going both ways. Weighted edges add a weight or value field. Self-loops set source equal to target. This format is ideal for sparse graphs (most real-world networks) because its O(V+E) space is far better than the O(V²) of an adjacency matrix when edges are few relative to the number of possible connections.

// Directed weighted graph — JSON adjacency list
{
  "nodes": [
    { "id": "A", "label": "Start",  "type": "source" },
    { "id": "B", "label": "Middle", "type": "intermediate" },
    { "id": "C", "label": "End",    "type": "sink" }
  ],
  "edges": [
    { "id": "e1", "source": "A", "target": "B", "weight": 4,  "label": "road" },
    { "id": "e2", "source": "A", "target": "C", "weight": 10, "label": "road" },
    { "id": "e3", "source": "B", "target": "C", "weight": 3,  "label": "road" },
    { "id": "e4", "source": "A", "target": "A", "weight": 0,  "label": "self-loop" }
  ]
}

// Build adjacency map from edges array
function buildAdjMap(graph) {
  const adj = {}
  for (const node of graph.nodes) adj[node.id] = []
  for (const edge of graph.edges) {
    if (edge.source !== edge.target) {   // skip self-loops for traversal
      adj[edge.source].push({ to: edge.target, weight: edge.weight })
    }
  }
  return adj
}
// adj["A"] => [{ to: "B", weight: 4 }, { to: "C", weight: 10 }]

When designing the node and edge schema, keep ids stable across updates — changing an id breaks all edges that reference it. Use UUIDs or database primary keys rather than sequential integers that may shift when nodes are deleted. See JSON Schema patterns for how to define a reusable schema for graph objects with $defs.

Nested Tree JSON Serialization

Trees are a special case of graphs with no cycles and a single root. The natural JSON representation is recursive: each node object holds its data plus a children array containing child node objects. Leaf nodes have an empty children array — never omit the field or use null, because that forces every consumer to handle two falsy cases instead of one. The depth of the JSON structure equals the depth of the tree, which can cause call stack overflow for very deep trees during recursive traversal.

// Nested tree JSON — file system hierarchy example
{
  "id": "root",
  "name": "/",
  "type": "directory",
  "children": [
    {
      "id": "src",
      "name": "src",
      "type": "directory",
      "children": [
        { "id": "index", "name": "index.ts", "type": "file", "size": 1024, "children": [] },
        { "id": "utils", "name": "utils.ts", "type": "file", "size": 2048, "children": [] }
      ]
    },
    {
      "id": "pkg",
      "name": "package.json",
      "type": "file",
      "size": 512,
      "children": []
    }
  ]
}

// Recursive DFS serialization from a flat node map
function buildTree(nodeId, parentMap) {
  const node = parentMap[nodeId]
  return {
    id: node.id,
    name: node.name,
    type: node.type,
    children: (node.childIds ?? []).map((cid) => buildTree(cid, parentMap)),
  }
}

// Flattening back to a parentId list (safe for deep trees)
function flattenTree(node, parentId = null, result = []) {
  result.push({ id: node.id, name: node.name, parentId })
  for (const child of node.children) flattenTree(child, node.id, result)
  return result
}

For trees deeper than ~500 levels, convert recursive DFS to an iterative approach using an explicit stack array. The flat parentId representation is preferable for database storage (single table, easy SQL queries) and avoids depth limits entirely. Reconstruct the tree from a flat list by building a node map and then wiring children arrays in a single pass.

D3.js JSON Graph Format

D3.js force-directed layouts expect a specific JSON shape: nodes array and links array (not edges). Each node object must have an idfield and can carry any additional properties (group, label, radius) that your rendering code reads via D3's datum binding. Each link object has source and target — either string ids or zero-based numeric indices. During simulation, D3 mutates these arrays in place, adding x, y, vx, vy to each node and resolving string ids to node object references on each link.

// D3.js force simulation input JSON
const graphData = {
  nodes: [
    { id: "alice",   group: 1, label: "Alice",   radius: 10 },
    { id: "bob",     group: 1, label: "Bob",     radius: 10 },
    { id: "charlie", group: 2, label: "Charlie", radius: 8  },
  ],
  links: [
    { source: "alice", target: "bob",     value: 3 },
    { source: "alice", target: "charlie", value: 1 },
  ],
}

// D3 force simulation setup
import * as d3 from 'd3'

const simulation = d3.forceSimulation(graphData.nodes)
  .force('link',   d3.forceLink(graphData.links)
                     .id((d) => d.id)          // ← resolve string ids
                     .distance(80)
                     .strength(0.5))
  .force('charge', d3.forceManyBody().strength(-200))
  .force('center', d3.forceCenter(width / 2, height / 2))
  .force('collision', d3.forceCollide().radius((d) => d.radius + 2))

simulation.on('tick', () => {
  // After each tick, nodes have updated x/y — re-render links and nodes
  link.attr('x1', (d) => d.source.x)
      .attr('y1', (d) => d.source.y)
      .attr('x2', (d) => d.target.x)
      .attr('y2', (d) => d.target.y)
  node.attr('cx', (d) => d.x)
      .attr('cy', (d) => d.y)
})

Always call simulation.force("link").id(d => d.id) when using string ids in the links array — without it, D3 defaults to zero-based array index resolution, which mismatches string ids and produces broken graphs. Pin nodes by setting fx and fyon the node object (non-null values freeze that node's position). Load the JSON from a URL with d3.json(url), which returns a promise. Validate the loaded structure with Jsonic's formatter before passing it to the simulation.

GeoJSON Structure

GeoJSON (RFC 7946) is the JSON standard for geographic graph data — points, lines, polygons, and multi-geometry collections. The root object is always a FeatureCollection when working with multiple geographic features. Each Feature has a geometry object (with type and coordinates) and a properties object for arbitrary non-geometric metadata. Coordinates are always [longitude, latitude] — the opposite of the conventional lat/lng order, which is a frequent source of bugs.

// GeoJSON FeatureCollection with mixed geometry types
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [-122.4194, 37.7749]   // [lng, lat]
      },
      "properties": { "name": "San Francisco", "population": 875000 }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [-122.4194, 37.7749],
          [-118.2437, 34.0522]
        ]
      },
      "properties": { "name": "SF to LA route", "mode": "road" }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [                          // outer ring
            [-122.50, 37.70],
            [-122.35, 37.70],
            [-122.35, 37.82],
            [-122.50, 37.82],
            [-122.50, 37.70]         // close the ring — repeat first point
          ]
          // additional arrays here would be interior "hole" rings
        ]
      },
      "properties": { "name": "SF bounding box" }
    }
  ]
}

Geometry types: Point (single coordinate pair), LineString (array of coordinate pairs), Polygon (array of rings — first is exterior, rest are holes), and their multi-variants (MultiPoint, MultiLineString, MultiPolygon). The Polygon's triple nesting — [[[lng,lat], ...]] — is the most common source of GeoJSON parsing errors. Always close polygon rings by repeating the first coordinate as the last. Use Jsonic to validate GeoJSON and check coordinate nesting depth before passing to mapping libraries.

Cytoscape.js JSON Format

Cytoscape.js is a graph visualization library for complex networks. Its JSON format uses a single elements array that interleaves both nodes and edges. Every element has a data object: nodes require data.id; edges require data.id, data.source, and data.target. Additional fields in data are available for styling rules and layout algorithms. Cytoscape also supports compound nodes via a data.parent field that makes one node a child of another (useful for group layouts).

// Cytoscape.js elements JSON
const cytoscapeData = {
  elements: [
    // Nodes
    { data: { id: "n1", label: "Server A",   group: "servers", weight: 5 } },
    { data: { id: "n2", label: "Server B",   group: "servers", weight: 3 } },
    { data: { id: "n3", label: "Database",   group: "storage", weight: 8 } },
    // Compound node (parent container)
    { data: { id: "cluster1", label: "Backend Cluster" } },
    // Children reference parent id
    { data: { id: "n4", label: "Worker 1", parent: "cluster1" } },
    { data: { id: "n5", label: "Worker 2", parent: "cluster1" } },

    // Edges
    { data: { id: "e1", source: "n1", target: "n3", weight: 10, label: "reads" } },
    { data: { id: "e2", source: "n2", target: "n3", weight: 6,  label: "reads" } },
    { data: { id: "e3", source: "n4", target: "n1", weight: 2,  label: "calls" } },
  ],
}

// Initialize Cytoscape with JSON
import cytoscape from 'cytoscape'

const cy = cytoscape({
  container: document.getElementById('cy'),
  elements: cytoscapeData.elements,
  style: [
    { selector: 'node', style: { label: 'data(label)', 'font-size': 12 } },
    { selector: 'edge', style: { width: 'data(weight)', 'line-color': '#ccc' } },
  ],
  layout: { name: 'cose' },
})

// Export current graph back to JSON
const exportedJson = cy.json()   // includes style, layout, zoom, pan
const elementsOnly = cy.elements().jsons()  // just the elements array

Load elements incrementally with cy.add(elements) for real-time graph updates without re-initializing the full layout. Use cy.json(graphData) to replace the entire graph. Access nodes and edges programmatically: cy.nodes(), cy.edges(), cy.getElementById('n1'). For large graphs (>1000 nodes), enable WebGL rendering with the cytoscape-canvas extension and pre-filter the elements JSON server-side to send only the visible subgraph.

Converting Between Graph Representations

Different libraries and algorithms expect different JSON graph formats. The three most common conversions are: adjacency list to matrix (for dense-graph algorithms), nested tree to flat parentId list (for database storage and incremental updates), and adjacency list to JSON-LD graph (for semantic web and schema.org markup). Each conversion is a straightforward one-pass or two-pass operation on the JSON arrays.

// 1. Adjacency list to adjacency matrix
function adjListToMatrix(graph) {
  const index = {}
  graph.nodes.forEach((n, i) => (index[n.id] = i))
  const n = graph.nodes.length
  const matrix = Array.from({ length: n }, () => new Array(n).fill(0))
  for (const edge of graph.edges) {
    matrix[index[edge.source]][index[edge.target]] = edge.weight ?? 1
    // For undirected: also set matrix[index[edge.target]][index[edge.source]]
  }
  return matrix
}
// matrix[0][1] => edge weight from node 0 to node 1

// 2. Nested tree to flat parentId list
function treeToFlat(node, parentId = null, result = []) {
  const { children, ...data } = node
  result.push({ ...data, parentId })
  for (const child of children ?? []) treeToFlat(child, node.id, result)
  return result
}

// 3. Flat parentId list back to nested tree
function flatToTree(flatNodes) {
  const map = {}
  for (const n of flatNodes) map[n.id] = { ...n, children: [] }
  let root = null
  for (const n of flatNodes) {
    if (n.parentId === null) root = map[n.id]
    else map[n.parentId].children.push(map[n.id])
  }
  return root
}

// 4. Adjacency list to JSON-LD graph (schema.org markup)
function adjListToJsonLd(graph, baseUrl) {
  return {
    '@context': 'https://schema.org',
    '@graph': [
      ...graph.nodes.map((n) => ({ '@id': `${baseUrl}/nodes/${n.id}`, '@type': 'Thing', name: n.label })),
      ...graph.edges.map((e) => ({ '@type': 'Action', agent: { '@id': `${baseUrl}/nodes/${e.source}` }, object: { '@id': `${baseUrl}/nodes/${e.target}` } })),
    ],
  }
}

When converting from D3 format to the generic adjacency list format, rename the links array to edges and map value to weight. Be aware that D3 mutates its nodes array in place during simulation — clone the data with structuredClone(graphData) before passing it to D3 if you need to preserve the original JSON. See JSON in GraphQL for how graph data travels through a GraphQL API layer.

Graph Traversal with JSON

Traversal algorithms operate on the adjacency map built from the JSON edges array. Build the map once — O(E) — and then run multiple traversals against it without re-parsing the JSON. DFS uses a call stack (recursive) or an explicit stack array (iterative); BFS uses a queue array. Both require a visited Set to handle cycles and avoid infinite loops on general graphs.

// Build adjacency map from JSON graph
function buildAdj(graph, directed = true) {
  const adj = {}
  for (const n of graph.nodes) adj[n.id] = []
  for (const e of graph.edges) {
    adj[e.source].push(e.target)
    if (!directed) adj[e.target].push(e.source)
  }
  return adj
}

// DFS — recursive (watch for stack overflow on deep graphs)
function dfs(adj, startId, visited = new Set()) {
  if (visited.has(startId)) return []
  visited.add(startId)
  const result = [startId]
  for (const neighbor of adj[startId] ?? []) {
    result.push(...dfs(adj, neighbor, visited))
  }
  return result
}

// BFS — iterative with queue
function bfs(adj, startId) {
  const visited = new Set([startId])
  const queue = [startId]
  const order = []
  while (queue.length > 0) {
    const current = queue.shift()
    order.push(current)
    for (const neighbor of adj[current] ?? []) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor)
        queue.push(neighbor)
      }
    }
  }
  return order
}

// Cycle detection — DFS with recursion stack
function hasCycle(adj) {
  const visited = new Set()
  const stack = new Set()
  function dfsCheck(nodeId) {
    visited.add(nodeId)
    stack.add(nodeId)
    for (const neighbor of adj[nodeId] ?? []) {
      if (!visited.has(neighbor) && dfsCheck(neighbor)) return true
      if (stack.has(neighbor)) return true   // back edge = cycle
    }
    stack.delete(nodeId)
    return false
  }
  for (const nodeId of Object.keys(adj)) {
    if (!visited.has(nodeId) && dfsCheck(nodeId)) return true
  }
  return false
}

// Topological sort (DFS post-order, directed acyclic graph only)
function topoSort(adj) {
  const visited = new Set()
  const order = []
  function dfsPost(nodeId) {
    if (visited.has(nodeId)) return
    visited.add(nodeId)
    for (const neighbor of adj[nodeId] ?? []) dfsPost(neighbor)
    order.unshift(nodeId)   // prepend = reverse post-order
  }
  for (const nodeId of Object.keys(adj)) dfsPost(nodeId)
  return order
}

For shortest path on a weighted JSON graph, implement Dijkstra's algorithm using a priority queue (min-heap). The edge weights come from the weight field on each edge object. For unweighted graphs, BFS naturally produces shortest paths by level. Topological sort only works on directed acyclic graphs (DAGs) — run cycle detection first. For tree JSON with children arrays, DFS and BFS work the same way but use node.children instead of looking up an adjacency map. See JSON query languages for declarative approaches to querying graph-shaped JSON.

Definitions

Adjacency list
A graph representation where each node stores a list of its neighboring nodes (or edge objects). In JSON, this is a nodes array and an edges array where each edge references its endpoint node ids. Uses O(V+E) space, making it efficient for sparse graphs where most node pairs are not directly connected.
Adjacency matrix
A V×V 2D array where matrix[i][j] holds the edge weight (or 1/0 for unweighted graphs) from node i to node j. In JSON, this is a 2D array of numbers. Efficient for dense graphs and O(1) edge lookup, but uses O(V²) space — impractical for large sparse graphs.
Node
A vertex in a graph, represented in JSON as an object with an id field and optional metadata fields (label, type, weight, group, etc.). Nodes are the entities that edges connect. In tree JSON, each node also carries a children array referencing its child node objects.
Edge
A connection between two nodes in a graph, represented in JSON as an object with source and target fields (node ids) and optional metadata (weight, label, id). Directed edges have a meaningful source-to-target direction; undirected edges connect both ways. Self-loops have the same value for source and target.
GeoJSON
An open standard (RFC 7946) for encoding geographic data structures in JSON. Supports geometry types Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, and GeometryCollection. All coordinates are [longitude, latitude] arrays. The standard container is a FeatureCollection holding Feature objects.
FeatureCollection
The top-level GeoJSON object type: {"type": "FeatureCollection", "features": [...]}. Contains an array of Feature objects, each pairing a geometry with a properties object. Supported by all major mapping libraries including Leaflet, Mapbox GL JS, and Turf.js.
Tree serialization
The process of converting an in-memory tree data structure into JSON. The standard format uses recursive children arrays, where each node object embeds its descendants inline. Alternative flat serialization stores all nodes in a single array with a parentId field, avoiding depth limits and simplifying database storage.
Directed graph
A graph in which edges have a direction from a source node to a target node. In JSON adjacency list format, a directed edge {source: "A", target: "B"} does not imply the reverse connection from B to A. Directed graphs support topological sort, which is impossible on undirected graphs. Trees and DAGs (directed acyclic graphs) are special cases of directed graphs.

Frequently asked questions

How do I represent a graph in JSON?

Use an adjacency list with two top-level arrays: {"nodes": [{"id": "A"},...], "edges": [{"source": "A", "target": "B", "weight": 1},...] }. Each node object holds an id and any metadata. Each edge holds source and target node ids plus optional attributes like weight or label. For undirected graphs, include one edge per pair; for directed, add edges only in the intended direction. Self-loops set source equal to target. The adjacency list uses O(V+E) space and is the preferred format for sparse graphs. The alternative adjacency matrix (a 2D JSON array) is O(V²) and only practical for dense graphs.

What is the D3.js JSON format for graphs?

D3.js force-directed graphs expect {nodes: [{id, ...},...], links: [{source, target, value},...] }. The nodes array contains objects with at least an id field; additional properties like group, label, or radius pass through to rendering code. The links array uses source and target referencing node ids (strings) or zero-based indices (numbers). D3 mutates the arrays during simulation, adding x, y, vx, and vy to each node. Set simulation.force("link").id(d => d.id) when using string ids so D3 resolves source/target references correctly.

What is GeoJSON?

GeoJSON is a JSON format for encoding geographic data structures (RFC 7946). The top-level object is a FeatureCollection: {"type": "FeatureCollection", "features": [...]}. Each Feature has a geometry (Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, or GeometryCollection) and a properties object for arbitrary metadata. Coordinates are [longitude, latitude] pairs — not lat/lng. Polygon coordinates use triple nesting: [[[lng,lat],[lng,lat],...]] — an array of rings, where the first ring is the outer boundary and subsequent rings are interior holes. GeoJSON is the standard for Leaflet, Mapbox GL JS, and PostGIS.

How do I serialize a tree in JSON?

Use recursive children arrays: {"id": "root", "name": "Root", "children": [{"id": "A", "children": []}, ...]}. Each node holds its data plus a children array (always an array — never null — for leaf nodes). Deserialize with a recursive function that maps each node and recurses into children. For trees deeper than ~500 levels, use an iterative stack-based approach to avoid call stack overflow. The alternative flat serialization stores all nodes in a single array with a parentId field, which avoids depth limits and simplifies database storage — reconstruct the tree with a two-pass node-map approach.

What is an adjacency list in JSON?

A JSON adjacency list is {nodes: [{id: "A"},...], edges: [{source: "A", target: "B"},...] } — one object per vertex and one object per connection. It uses O(V+E) space, making it far more efficient than an adjacency matrix (O(V²)) for sparse graphs. Most real-world graphs — social networks, dependency trees, road networks — have far fewer edges than the maximum V² possible, so the adjacency list is the default choice. Weighted edges add a weight field; labeled edges add a label field. Build an in-memory adjacency map from the edges array for O(1) neighbor lookup during traversal.

How do I convert a graph adjacency list to a matrix?

Build an index mapping node ids to integer positions, create an n×n 2D array initialized to 0, then iterate over the edges array and set matrix[sourceIndex][targetIndex] = weight. In JavaScript: const index = {}; nodes.forEach((n, i) => index[n.id] = i); const matrix = Array.from({length: n}, () => new Array(n).fill(0)); edges.forEach(e => { matrix[index[e.source]][index[e.target]] = e.weight ?? 1; });For undirected graphs, also set matrix[index[e.target]][index[e.source]]. The result is a JSON-serializable 2D array. Note that the matrix uses O(V²) space — for 1000 nodes that is one million entries — so only convert when a matrix-specific algorithm (like Floyd-Warshall) requires it.

How do I traverse a JSON graph structure?

First build an adjacency map from the edges array, then run DFS or BFS. DFS (recursive): if visited.has(nodeId) return; add to visited; recurse into each neighbor. BFS (iterative): start with a queue containing the start node and a visited Set; loop: dequeue current, enqueue unvisited neighbors, add them to visited. Cycle detection: maintain a recursion stack in DFS — if a neighbor is already in the current recursion stack, a back edge (cycle) exists. For topological sort on a DAG, use DFS post-order and prepend each node to the result after recursing into all descendants.

What is the Cytoscape.js JSON format?

Cytoscape.js uses a single elements array containing both nodes and edges: {elements: [{data: {id: "A"}}, {data: {id: "AB", source: "A", target: "B"}}, ...] }. Node elements have data.id; edge elements have data.id, data.source, and data.target. Both carry arbitrary extra fields in data for styling and layout. Compound nodes use a data.parent field to reference a container node id. Import with cy.add(elements) for incremental updates or cy.json(graphData) to replace the entire graph. Export back with cy.json() (full state) or cy.elements().jsons() (elements array only).

Further reading and primary sources

Validate and explore your graph JSON

Paste any adjacency list, D3.js nodes/links, or GeoJSON into Jsonic to pretty-print, validate, and navigate the nested structure. Instantly spot missing ids, incorrect coordinate nesting, or malformed edge references before running your visualization.

Open JSON Formatter