
Product
Introducing Repository Access Permissions and Custom Roles
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.
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:
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.
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 | unset or local | 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.
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
LEANGRAPH_MODE=remote
LEANGRAPH_API_KEY=lg_xxx
// 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.
const db = await LeanGraph({ mode: 'test', project: 'myapp' });
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.
LeanGraph(options): Promise<LeanGraphClient>Create a new LeanGraph client. Returns a promise that resolves to a client instance.
db.query<T>(cypher, params?): Promise<T[]>Execute a Cypher query and return results as an array.
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 }]
db.execute(cypher, params?): Promise<void>Execute a mutating query (CREATE, SET, DELETE, MERGE) without expecting return data.
await db.execute('CREATE (n:User {name: $name, email: $email})', {
name: 'Alice',
email: 'alice@example.com'
});
db.queryRaw<T>(cypher, params?): Promise<QueryResponse<T>>Execute a query and return the full response including metadata.
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:
db.createNode(label, properties?): Promise<string>
db.getNode(label, filter): Promise<Record<string, unknown> | null>
db.updateNode(id, properties): Promise<void>
db.deleteNode(id): Promise<void>
db.createEdge(sourceId, type, targetId, properties?): Promise<void>
db.health(): Promise<{ status: string; timestamp: string }>Check server health. In development mode, always returns { status: 'ok', ... }.
db.close(): voidClose the client and release resources. Always call this when done.
const db = await LeanGraph({ ... });
try {
// ... use db
} finally {
db.close();
}
// 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:
// 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`);
interface User {
name: string;
email: string;
}
const users = await db.query<User>(
'MATCH (u:User) RETURN u.name AS name, u.email AS email'
);
users[0].name; // TypeScript knows this is string
// 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' });
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' });
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 });
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}`);
console.error(`Position: line ${err.line}, column ${err.column}`);
}
}
Use test mode for fast, isolated tests:
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 |
CREATE INDEX | CREATE INDEX ON (property) |
DROP INDEX | DROP INDEX idx_name |
CREATE CONSTRAINT | CREATE CONSTRAINT ON (n:Label) ASSERT n.prop IS UNIQUE |
DROP CONSTRAINT | DROP CONSTRAINT constraint_name |
| Category | Operators |
|---|---|
| Comparison | =, <>, <, >, <=, >= |
| Logical | AND, OR, NOT |
| String | CONTAINS, STARTS WITH, ENDS WITH |
| List | IN |
| Null | IS NULL, IS NOT NULL |
| Pattern | EXISTS |
| 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
-- Find friends of friends (1 to 3 hops)
MATCH (a:User {name: 'Alice'})-[:KNOWS*1..3]->(b:User)
RETURN DISTINCT b.name
-- 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
Create indexes on frequently-queried properties to improve performance:
-- Create index (auto-named idx_email)
CREATE INDEX ON (email)
-- Create index with custom name
CREATE INDEX idx_user_email ON (email)
-- Drop index
DROP INDEX idx_user_email
Note: The :Label syntax is supported for Neo4j compatibility but labels are ignored—all indexes are global across nodes.
-- These are equivalent (label is ignored)
CREATE INDEX ON (email)
CREATE INDEX ON :User(email)
Built-in indexes (created automatically):
Create unique constraints to enforce data integrity:
-- Create unique constraint (auto-named constraint_User_email_unique)
CREATE CONSTRAINT ON (n:User) ASSERT n.email IS UNIQUE
-- Create constraint with custom name
CREATE CONSTRAINT unique_user_email ON (u:User) ASSERT u.email IS UNIQUE
-- Drop constraint
DROP CONSTRAINT unique_user_email
Unique constraints:
await db.execute('CREATE CONSTRAINT ON (u:User) ASSERT u.email IS UNIQUE');
await db.execute('CREATE (u:User {email: "alice@example.com"})');
// This will fail with "UNIQUE constraint failed"
await db.execute('CREATE (u:User {email: "alice@example.com"})');
For production deployments, run a dedicated server:
# Start the server
npx leangraph serve --port 3000 --data ./data
# Or with custom host binding
npx leangraph serve --port 3000 --host 0.0.0.0 --data ./data
# Create a new project (generates API key)
npx leangraph create myapp --data ./data
# Output:
# [created] production/myapp.db
# API Key: lg_abc123...
# Server
leangraph serve [options]
-p, --port <port> Port to listen on (default: 3000)
-d, --data <path> Data directory (default: /var/data/leangraph)
-H, --host <host> Host to bind to (default: localhost)
-b, --backup <path> Backup directory (enables backup endpoints)
# Project management
leangraph create <project> Create new project with API keys
leangraph delete <project> Delete project (use --force)
leangraph list List all projects
# Environment management
leangraph clone <project> --from <env> --to <env> Copy between environments
leangraph wipe <project> --env <env> Clear environment database
# Direct queries
leangraph query <env> <project> "CYPHER"
# Backup
leangraph backup [options]
-o, --output <path> Backup directory
-p, --project <name> Backup specific project
--status Show backup status
# API keys
leangraph apikey add <project>
leangraph apikey list
leangraph apikey remove <prefix>
For advanced use cases, you can access the underlying components:
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);
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 });
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:
// 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:
// 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
FAQs
SQLite-based graph database with Cypher query support
The npm package leangraph receives a total of 35 weekly downloads. As such, leangraph popularity was classified as not popular.
We found that leangraph demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Product
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.

Product
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.

Product
Socket Firewall blocks malicious VS Code and Open VSX extensions before install, protecting developers from compromised editor marketplaces.