SQLite-based graph database with Cypher query support
npm install leangraph


A lightweight, embeddable graph database with full Cypher query support, powered by SQLite.
> 100% openCypher TCK Compliance — LeanGraph passes all 2,684 test scenarios from the openCypher Technology Compatibility Kit (Neo4j 3.5 baseline). Every Cypher feature that Neo4j 3.5 supports, LeanGraph supports.
| Feature | LeanGraph | Neo4j |
|---------|-----------|-------|
| Startup time | Instant | 30+ seconds |
| Memory | ~50MB | 1GB+ minimum |
| Deployment | Single npm package | JVM + complex setup |
| Docker required | No | Typically yes |
| Works offline | Yes | Server required |
| Backup | Copy the SQLite file | Enterprise license |
| Cypher support | Full (Neo4j 3.5 parity) | Full |
| Cost | Free, MIT license | Free tier limited |
LeanGraph is ideal for:
- Production graph workloads with zero infrastructure
- Neo4j-level queries without Neo4j-level complexity
- Self-hosted apps where simplicity is a feature
- Instant local databases for development and testing
``bash`
npm install leangraph
npm install -D better-sqlite3
better-sqlite3 is only needed for local and test modes. Production deployments using remote mode don't require it, keeping your node_modules lean and avoiding native rebuilds.
`typescript
import { LeanGraph } from 'leangraph';
const db = await LeanGraph({ project: 'myapp' });
// Create nodes and relationships
await db.execute(
CREATE (alice:User {name: 'Alice'})-[:FOLLOWS]->(bob:User {name: 'Bob'}));
// Query the graph
const users = await db.query('MATCH (u:User) RETURN u.name AS name');
console.log(users); // [{ name: 'Alice' }, { name: 'Bob' }]
db.close();
`
| Mode | LEANGRAPH_MODE | Behavior |local
|------|------------------|----------|
| Local | unset or | Embedded SQLite at ./data/{project}.db |remote
| Remote | | HTTP connection to LeanGraph server |test
| Test | | In-memory SQLite (resets on restart) |
Uses an embedded SQLite database. No server required.
`typescript`
const db = await LeanGraph({ project: 'myapp' });
// Data persists at ./data/myapp.db
Your code can stay identical for local development and production. Just configure environment variables:
.env
`bash`
LEANGRAPH_MODE=remote
LEANGRAPH_API_KEY=lg_xxx
`typescript`
// Same code works locally (dev) and remotely (production)
const db = await LeanGraph({ project: 'myapp' });
When LEANGRAPH_MODE=remote is set, LeanGraph automatically connects via HTTP instead of embedded LeanGraph.
> Tip: Remote mode doesn't use better-sqlite3, so installing it as a dev dependency speeds up production deploys by skipping native module compilation.
Uses an in-memory SQLite database that resets when the process exits.
`typescript`
const db = await LeanGraph({ mode: 'test', project: 'myapp' });
`typescript`
interface LeanGraphOptions {
mode?: "local" | "remote" | "test";
project?: string;
url?: string;
apiKey?: string;
dataPath?: string;
}
| Option | Environment Variable | Default | Description |
|--------|---------------------|---------|-------------|
| mode | LEANGRAPH_MODE | "local" | local, remote, or test |project
| | LEANGRAPH_PROJECT | — | Project name (required) |url
| | LEANGRAPH_URL | "https://leangraph.io" | Server URL (remote mode) |apiKey
| | LEANGRAPH_API_KEY | — | API key (remote mode) |dataPath
| | LEANGRAPH_DATA_PATH | "./data" | Data directory (local mode) |
Options passed to LeanGraph() take precedence over environment variables.
Create a new LeanGraph client. Returns a promise that resolves to a client instance.
Execute a Cypher query and return results as an array.
`typescript`
const users = await db.query<{ name: string; age: number }>(
'MATCH (u:User) WHERE u.age > $minAge RETURN u.name AS name, u.age AS age',
{ minAge: 21 }
);
// users = [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }]
Execute a mutating query (CREATE, SET, DELETE, MERGE) without expecting return data.
`typescript`
await db.execute('CREATE (n:User {name: $name, email: $email})', {
name: 'Alice',
email: 'alice@example.com'
});
Execute a query and return the full response including metadata.
`typescript`
const response = await db.queryRaw('MATCH (n) RETURN n LIMIT 10');
console.log(response.meta.count); // Number of rows
console.log(response.meta.time_ms); // Query execution time in ms
console.log(response.data); // Array of results
Thin wrappers around common Cypher operations:
`typescript`
db.createNode(label, properties?): Promise
db.getNode(label, filter): Promise
db.updateNode(id, properties): Promise
db.deleteNode(id): Promise
db.createEdge(sourceId, type, targetId, properties?): Promise
Check server health. In development mode, always returns { status: 'ok', ... }.
Close the client and release resources. Always call this when done.
`typescript`
const db = await LeanGraph({ ... });
try {
// ... use db
} finally {
db.close();
}
`typescript
// Create
await db.execute(
'CREATE (u:User {name: $name, email: $email})',
{ name: 'Alice', email: 'alice@example.com' }
);
// Read
const [user] = await db.query<{ name: string; email: string }>(
'MATCH (u:User {email: $email}) RETURN u.name AS name, u.email AS email',
{ email: 'alice@example.com' }
);
// Update
await db.execute(
'MATCH (u:User {email: $email}) SET u.verified = true',
{ email: 'alice@example.com' }
);
// Delete
await db.execute(
'MATCH (u:User {email: $email}) DETACH DELETE u',
{ email: 'alice@example.com' }
);
`
Always use parameters for user input:
`typescript
// Good - parameterized
const users = await db.query(
'MATCH (u:User) WHERE u.email = $email RETURN u',
{ email: userInput }
);
// Bad - string interpolation (injection risk)
const users = await db.query(MATCH (u:User) WHERE u.email = '${userInput}' RETURN u);`
`typescript
interface User {
name: string;
email: string;
}
const users = await db.query
'MATCH (u:User) RETURN u.name AS name, u.email AS email'
);
users[0].name; // TypeScript knows this is string
`
`typescript
// Create a relationship
await db.execute(
MATCH (a:User {name: $from}), (b:User {name: $to})
CREATE (a)-[:FOLLOWS {since: $since}]->(b), { from: 'Alice', to: 'Bob', since: '2024-01-01' });
// Query relationships
const following = await db.query<{ name: string }>(
MATCH (:User {name: $name})-[:FOLLOWS]->(friend:User)
RETURN friend.name AS name, { name: 'Alice' });
// Variable-length paths (1-3 hops)
const connections = await db.query<{ name: string }>(
MATCH (:User {name: $name})-[:FOLLOWS*1..3]->(connection:User)
RETURN DISTINCT connection.name AS name, { name: 'Alice' });`
`typescript
await db.execute(
MERGE (u:User {email: $email})
ON CREATE SET u.name = $name, u.createdAt = datetime()
ON MATCH SET u.lastSeen = datetime(), { email: 'alice@example.com', name: 'Alice' });`
`typescript
const users = [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
];
await db.execute(
UNWIND $users AS data
CREATE (u:User {name: data.name, email: data.email}), { users });`
`typescript
import { LeanGraph, LeanGraphError } from 'leangraph';
try {
await db.query('MATCH (n:User RETURN n'); // syntax error
} catch (err) {
if (err instanceof LeanGraphError) {
console.error(Query failed: ${err.message});Position: line ${err.line}, column ${err.column}
console.error();`
}
}
Use test mode for fast, isolated tests:
`typescript
import { LeanGraph } from 'leangraph';
const db = await LeanGraph({ mode: 'test', project: 'test' });
// Tests run against in-memory database
await db.execute('CREATE (u:User {name: $name})', { name: 'Test' });
const [user] = await db.query('MATCH (u:User) RETURN u.name AS name');
assert(user.name === 'Test');
db.close(); // In-memory DB is discarded
`
| Clause | Example |
|--------|---------|
| CREATE | CREATE (n:User {name: 'Alice'}) |MATCH
| | MATCH (n:User) RETURN n |OPTIONAL MATCH
| | OPTIONAL MATCH (n)-[:KNOWS]->(m) RETURN m |MERGE
| | MERGE (n:User {email: $email}) |WHERE
| | WHERE n.age > 21 AND n.active = true |SET
| | SET n.name = 'Bob', n.updated = true |DELETE
| | DELETE n |DETACH DELETE
| | DETACH DELETE n |RETURN
| | RETURN n.name AS name, count(*) AS total |WITH
| | WITH n, count(*) AS cnt WHERE cnt > 1 |UNWIND
| | UNWIND $list AS item CREATE (n {value: item}) |UNION / UNION ALL
| | MATCH (n:A) RETURN n UNION MATCH (m:B) RETURN m |ORDER BY
| | ORDER BY n.name DESC |SKIP / LIMIT
| | SKIP 10 LIMIT 5 |DISTINCT
| | RETURN DISTINCT n.category |CASE/WHEN
| | RETURN CASE WHEN n.age > 18 THEN 'adult' ELSE 'minor' END |CALL
| | CALL db.labels() YIELD label RETURN label |
| Category | Operators |
|----------|-----------|
| Comparison | =, <>, <, >, <=, >= |AND
| Logical | , OR, NOT |CONTAINS
| String | , STARTS WITH, ENDS WITH |IN
| List | |IS NULL
| Null | , IS NOT NULL |EXISTS
| Pattern | |+
| Arithmetic | , -, *, /, % |
Aggregation: COUNT, SUM, AVG, MIN, MAX, COLLECT
Scalar: ID, coalesce
String: toUpper, toLower, trim, substring, replace, toString, split
List: size, head, last, tail, keys, range
Node/Relationship: labels, type, properties
Math: abs, ceil, floor, round, rand, sqrt
Date/Time: date, datetime, timestamp
`cypher`
-- Find friends of friends (1 to 3 hops)
MATCH (a:User {name: 'Alice'})-[:KNOWS*1..3]->(b:User)
RETURN DISTINCT b.name
`cypher
-- List all labels
CALL db.labels() YIELD label RETURN label
-- List all relationship types
CALL db.relationshipTypes() YIELD type RETURN type
-- List all property keys
CALL db.propertyKeys() YIELD key RETURN key
`
For production deployments, run a dedicated server:
`bashStart the server
npx leangraph serve --port 3000 --data ./data
$3
`bash
Create a new project (generates API key)
npx leangraph create myapp --data ./dataOutput:
[created] production/myapp.db
API Key: lg_abc123...
`$3
`bash
Server
leangraph serve [options]
-p, --port Port to listen on (default: 3000)
-d, --data Data directory (default: /var/data/leangraph)
-H, --host Host to bind to (default: localhost)
-b, --backup Backup directory (enables backup endpoints)Project management
leangraph create Create new project with API keys
leangraph delete Delete project (use --force)
leangraph list List all projectsEnvironment management
leangraph clone --from --to Copy between environments
leangraph wipe --env Clear environment databaseDirect queries
leangraph query "CYPHER"Backup
leangraph backup [options]
-o, --output Backup directory
-p, --project Backup specific project
--status Show backup statusAPI keys
leangraph apikey add
leangraph apikey list
leangraph apikey remove
`Advanced Usage
$3
For advanced use cases, you can access the underlying components:
`typescript
import { GraphDatabase, Executor, parse, translate } from 'leangraph';// Direct database access
const db = new GraphDatabase('./my-database.db');
db.initialize();
const executor = new Executor(db);
const result = executor.execute('MATCH (n) RETURN n LIMIT 10');
db.close();
// Parse Cypher to AST
const parseResult = parse('MATCH (n:User) RETURN n');
if (parseResult.success) {
console.log(parseResult.query);
}
// Translate AST to SQL
const translation = translate(parseResult.query, {});
console.log(translation.statements);
`$3
`typescript
import { createServer } from 'leangraph';
import { serve } from '@hono/node-server';const { app, dbManager } = createServer({
dataPath: './data',
apiKeys: {
'my-api-key': { project: 'myapp', env: 'production' }
}
});
serve({ fetch: app.fetch, port: 3000 });
`Known Limitations
$3
JavaScript cannot precisely represent integers larger than
Number.MAX_SAFE_INTEGER (9,007,199,254,740,991). Integers beyond this range will lose precision, which can cause unexpected behavior when comparing values.Example of the problem:
`javascript
// These two different numbers become equal in JavaScript!
const a = 4611686018427387905;
const b = 4611686018427387900;
console.log(a === b); // true (both round to 4611686018427388000)
`Workaround: Use strings for large integer IDs:
`cypher
// Instead of:
CREATE (u:User {id: 4611686018427387905})// Use strings:
CREATE (u:User {id: '4611686018427387905'})
MATCH (u:User {id: '4611686018427387905'}) RETURN u
``This limitation affects all JavaScript-based systems, including Neo4j's JavaScript driver. For IDs that may exceed the safe integer range, string representation is the recommended approach.
MIT - Conrad Lelubre