Apache AGE
Quick Start
CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
SELECT create_graph('my_graph');
SELECT * FROM cypher('my_graph', $$
CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})
RETURN a, b
$$) AS (a agtype, b agtype);
Reference Loading
| Principle |
Reference |
When to Load |
| The Wrapper Contract |
the-wrapper-contract.md |
MANDATORY for first-time AGE users. Load for cypher() mechanics, search_path, parameters, unsupported features |
| agtype Is Everything |
agtype-is-everything.md |
Load when casting agtype to SQL types, debugging type errors, or handling nulls |
| Hybrid Is the Point |
hybrid-is-the-point.md |
MANDATORY before writing any SQL+Cypher query. CTEs, JOINs, mutation guards, multi-graph |
| Schema Is Storage |
schema-is-storage.md |
Load when designing graph schema, choosing labels/properties, or modeling relationships |
| Nothing Is Automatic |
nothing-is-automatic.md |
MANDATORY before performance work. Indexes, EXPLAIN, batch loading, when to use SQL instead |
Do NOT load:
- schema-is-storage.md for pure query questions (no schema design involved)
- nothing-is-automatic.md for schema design unless indexing is the concern
- the-wrapper-contract.md for users already familiar with cypher() mechanics
Freedom calibration:
- Low freedom (follow exactly): cypher() wrapper syntax, AS column lists, search_path, index DDL, mutation CTE guards
- Medium freedom (adapt to context): graph modeling, hybrid query composition, performance tuning
- High freedom (choose freely): property naming, schema partitioning, application-layer validation
Think in AGE
- Every Cypher query is a SQL function call — plan the SQL wrapper first, then the Cypher inside it
- agtype is the only type that crosses the boundary — cast explicitly at every SQL touchpoint
- Graphs are PostgreSQL schemas — labels are tables, vertices and edges are rows with agtype property blobs
- Nothing exists until you create it — no default indexes, no property constraints, no type enforcement
- Hybrid is the value proposition — when a query is pure aggregation or pure OLTP, drop to SQL (it's ~15x faster for aggregation per Issue #2194)
Critical Gotchas
1. Always define a column list
cypher() returns SETOF record — PostgreSQL requires an explicit column list:
SELECT * FROM cypher('g', $$ MATCH (n) RETURN n $$) AS (n agtype);
2. Terminal clauses need a dummy column
CREATE, SET, DELETE at the end of a query return no rows, but the column list is still mandatory:
SELECT * FROM cypher('g', $$
CREATE (:Person {name: 'Alice'})
$$) AS (v agtype);
-- Returns 0 rows
3. SET = replaces all properties; use += to merge
-- Replaces ALL properties with just {age: 30}
SET n = {age: 30}
-- Merges {age: 30} into existing properties
SET n += {age: 30}
4. CREATE/SET/REMOVE not allowed in JOINs
Mutations inside a JOIN cause errors. Wrap in a CTE instead:
WITH new_vertex AS (
SELECT * FROM cypher('g', $$ CREATE (p:Person {name: 'Alice'}) RETURN p $$) AS (v agtype)
)
SELECT * FROM new_vertex;
5. search_path must include ag_catalog
SET search_path = ag_catalog, "$user", public;
-- Or fully qualify: SELECT * FROM ag_catalog.cypher(...)
NEVER
- NEVER omit the AS column list —
cypher() returns SETOF record, PostgreSQL demands it
- NEVER use
SET n = {...} when you mean SET n += {...} — = replaces ALL properties silently
- NEVER put CREATE/SET/REMOVE inside a JOIN — wrap in CTE; mutations break PostgreSQL transaction handling
- NEVER use unbounded
[:REL*] — paths explode at scale (7s → 7min on 1.5M vertices, Issue #195)
- NEVER assume indexes exist — AGE creates none by default; use EXPLAIN inside Cypher to verify
- NEVER compare agtype to SQL types without casting — agtype can only cast to
varchar among string types (not json/text, Issues #1225, #1996)
- NEVER query without a label — scans entire
_ag_label_vertex parent table
- NEVER use multiple labels on one vertex — AGE supports exactly zero or one (by design, Discussion #109)
- NEVER build maps with 50+ fields —
agtype_build_map() hits the 100-argument PostgreSQL function limit (Issue #1840)
- NEVER pass null to agtype functions without checking — some null inputs crash the server (Issues #1956, #1303)
Common Recipes
1-hop neighbors with SQL JOIN
SELECT u.email, friend.name::varchar AS friend_name
FROM users u
JOIN (
SELECT * FROM cypher('social', $$
MATCH (p:Person)-[:KNOWS]->(f:Person)
WHERE p.userId = 1042
RETURN p.userId, f.name
$$) AS (user_id agtype, name agtype)
) g ON u.id = g.user_id::int;
MERGE upsert
SELECT * FROM cypher('g', $$
MERGE (p:Person {email: 'alice@example.com'})
ON CREATE SET p.created_at = timestamp(), p.login_count = 1
ON MATCH SET p.login_count = p.login_count + 1
RETURN p
$$) AS (p agtype);
UNWIND bulk create
SELECT * FROM cypher('g', $$
UNWIND [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}] AS attrs
CREATE (p:Person {name: attrs.name, age: attrs.age})
RETURN p.name
$$) AS (name agtype);
Bounded path search
SELECT * FROM cypher('g', $$
MATCH (a:Person {name: 'Alice'})-[:KNOWS*1..3]->(b:Person)
RETURN DISTINCT b.name
LIMIT 100
$$) AS (name agtype);
1---2name: apache-age3description: Apache AGE graph database extension for PostgreSQL — Cypher query syntax, agtype data types, hybrid SQL+Cypher patterns (CTEs, JOINs, parameter passing), graph schema modeling (vertices, edges, labels, properties), and performance optimization (indexing, EXPLAIN, batch loading). Use when writing Cypher queries, designing graph schemas, combining graph traversal with relational SQL, or optimizing AGE query performance — all within PostgreSQL via the AGE extension.4---56# Apache AGE78## Quick Start910```sql11CREATE EXTENSION IF NOT EXISTS age;12LOAD 'age';13SET search_path = ag_catalog, "$user", public;14SELECT create_graph('my_graph');1516SELECT * FROM cypher('my_graph', $$17 CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})18 RETURN a, b19$$) AS (a agtype, b agtype);20```2122## Reference Loading2324| Principle | Reference | When to Load |25|-----------|-----------|--------------|26| The Wrapper Contract | [the-wrapper-contract.md](references/the-wrapper-contract.md) | **MANDATORY** for first-time AGE users. Load for cypher() mechanics, search_path, parameters, unsupported features |27| agtype Is Everything | [agtype-is-everything.md](references/agtype-is-everything.md) | Load when casting agtype to SQL types, debugging type errors, or handling nulls |28| Hybrid Is the Point | [hybrid-is-the-point.md](references/hybrid-is-the-point.md) | **MANDATORY** before writing any SQL+Cypher query. CTEs, JOINs, mutation guards, multi-graph |29| Schema Is Storage | [schema-is-storage.md](references/schema-is-storage.md) | Load when designing graph schema, choosing labels/properties, or modeling relationships |30| Nothing Is Automatic | [nothing-is-automatic.md](references/nothing-is-automatic.md) | **MANDATORY** before performance work. Indexes, EXPLAIN, batch loading, when to use SQL instead |3132**Do NOT load:**3334- schema-is-storage.md for pure query questions (no schema design involved)35- nothing-is-automatic.md for schema design unless indexing is the concern36- the-wrapper-contract.md for users already familiar with cypher() mechanics3738**Freedom calibration:**3940- **Low freedom** (follow exactly): cypher() wrapper syntax, AS column lists, search_path, index DDL, mutation CTE guards41- **Medium freedom** (adapt to context): graph modeling, hybrid query composition, performance tuning42- **High freedom** (choose freely): property naming, schema partitioning, application-layer validation4344## Think in AGE4546- Every Cypher query is a SQL function call — plan the SQL wrapper first, then the Cypher inside it47- agtype is the only type that crosses the boundary — cast explicitly at every SQL touchpoint48- Graphs are PostgreSQL schemas — labels are tables, vertices and edges are rows with agtype property blobs49- Nothing exists until you create it — no default indexes, no property constraints, no type enforcement50- Hybrid is the value proposition — when a query is pure aggregation or pure OLTP, drop to SQL (it's ~15x faster for aggregation per Issue #2194)5152## Critical Gotchas5354### 1. Always define a column list5556`cypher()` returns `SETOF record` — PostgreSQL requires an explicit column list:5758```sql59SELECT * FROM cypher('g', $$ MATCH (n) RETURN n $$) AS (n agtype);60```6162### 2. Terminal clauses need a dummy column6364CREATE, SET, DELETE at the end of a query return no rows, but the column list is still mandatory:6566```sql67SELECT * FROM cypher('g', $$68 CREATE (:Person {name: 'Alice'})69$$) AS (v agtype);70-- Returns 0 rows71```7273### 3. SET = replaces all properties; use += to merge7475```cypher76-- Replaces ALL properties with just {age: 30}77SET n = {age: 30}7879-- Merges {age: 30} into existing properties80SET n += {age: 30}81```8283### 4. CREATE/SET/REMOVE not allowed in JOINs8485Mutations inside a JOIN cause errors. Wrap in a CTE instead:8687```sql88WITH new_vertex AS (89 SELECT * FROM cypher('g', $$ CREATE (p:Person {name: 'Alice'}) RETURN p $$) AS (v agtype)90)91SELECT * FROM new_vertex;92```9394### 5. search_path must include ag_catalog9596```sql97SET search_path = ag_catalog, "$user", public;98-- Or fully qualify: SELECT * FROM ag_catalog.cypher(...)99```100101## NEVER102103- NEVER omit the AS column list — `cypher()` returns SETOF record, PostgreSQL demands it104- NEVER use `SET n = {...}` when you mean `SET n += {...}` — `=` replaces ALL properties silently105- NEVER put CREATE/SET/REMOVE inside a JOIN — wrap in CTE; mutations break PostgreSQL transaction handling106- NEVER use unbounded `[:REL*]` — paths explode at scale (7s → 7min on 1.5M vertices, Issue #195)107- NEVER assume indexes exist — AGE creates none by default; use EXPLAIN inside Cypher to verify108- NEVER compare agtype to SQL types without casting — agtype can only cast to `varchar` among string types (not json/text, Issues #1225, #1996)109- NEVER query without a label — scans entire `_ag_label_vertex` parent table110- NEVER use multiple labels on one vertex — AGE supports exactly zero or one (by design, Discussion #109)111- NEVER build maps with 50+ fields — `agtype_build_map()` hits the 100-argument PostgreSQL function limit (Issue #1840)112- NEVER pass null to agtype functions without checking — some null inputs crash the server (Issues #1956, #1303)113114## Common Recipes115116### 1-hop neighbors with SQL JOIN117118```sql119SELECT u.email, friend.name::varchar AS friend_name120FROM users u121JOIN (122 SELECT * FROM cypher('social', $$123 MATCH (p:Person)-[:KNOWS]->(f:Person)124 WHERE p.userId = 1042125 RETURN p.userId, f.name126 $$) AS (user_id agtype, name agtype)127) g ON u.id = g.user_id::int;128```129130### MERGE upsert131132```sql133SELECT * FROM cypher('g', $$134 MERGE (p:Person {email: 'alice@example.com'})135 ON CREATE SET p.created_at = timestamp(), p.login_count = 1136 ON MATCH SET p.login_count = p.login_count + 1137 RETURN p138$$) AS (p agtype);139```140141### UNWIND bulk create142143```sql144SELECT * FROM cypher('g', $$145 UNWIND [{name: 'Alice', age: 30}, {name: 'Bob', age: 25}] AS attrs146 CREATE (p:Person {name: attrs.name, age: attrs.age})147 RETURN p.name148$$) AS (name agtype);149```150151### Bounded path search152153```sql154SELECT * FROM cypher('g', $$155 MATCH (a:Person {name: 'Alice'})-[:KNOWS*1..3]->(b:Person)156 RETURN DISTINCT b.name157 LIMIT 100158$$) AS (name agtype);159```