Skip to main content
Guide
HelixDB accepts SQL statements alongside native queries and Cypher. It translates each statement into its Cypher profile and runs it on the same graph, transactions, and indexes, so data written in one language is visible to the others. A table is a node label, a column is a property, and a join is a relationship. The translation reads no schema: it follows fixed naming rules, which you can adjust with mappings. The translation is a port of the SQL-to-Cypher translator in the Neo4j JDBC driver. It covers SELECT, INSERT, UPDATE, DELETE, and TRUNCATE. Everything in What is supported runs, and everything it cannot translate faithfully is rejected with a specific error before the statement reads or writes anything.
SQL is served by Helix Cloud gateways, and by local servers, embedded databases, and SDKs built from the repository checkout. The CLI does not expose SQL yet.

Run a statement

Send one statement, with optional parameters, mappings, and a query name, to POST /v2/sql. The server translates it and works out whether it reads or writes. Parameters given as a list bind each ? in order.
HelixDB runs the statement as this Cypher:
A successful response has ordered columns and rectangular rows, with one value per column in column order, as for Cypher:
query is required; parameters, query_name, and mappings are optional. A result column keeps its SQL name: an unqualified column is named after the column, an alias after the alias, and any other entry after its text as written, such as p.name or COUNT(*). Repeated names are numbered: SELECT 1, 1 returns the columns 1 and 1_2. Duplicate rows are kept unless the statement uses DISTINCT or GROUP BY, row order is only defined by ORDER BY, and a write without RETURNING succeeds with empty columns and rows.

Over HTTP

A local server has no authentication:
A Helix Cloud gateway takes the same body. Authenticate as for Cypher, with the cluster API key and, on a GA shared gateway, the database ID:
The gateway translates each statement to route it by what it does: reads go to readers, and writes go to the elected writer. A read-only API key can read and explain, but receives 403 for a statement that writes. The HTTP API reference describes the request, status codes, and errors in full. A local server’s gRPC API serves the same bodies through ExecuteSql and ExplainSql.

Request options

The native request options apply to SQL too: Rust and TypeScript set options on request_builder() / requestBuilder() before .sql(...); Go and Python pass them to SQL and sql. Embedded clients reject request options.

Explain a statement

POST /v2/sql/explain takes the same body, translates the statement, and plans the translated Cypher without running it, exactly like explaining Cypher. Explaining a write changes nothing. Planning checks parameters, so supply every parameter the statement uses.

What is supported

Create and read a small graph:

Tables and columns

Labels and properties are case-sensitive and keep the case you write; table names, aliases, and mapping keys are not. Schema prefixes such as public.Person are ignored. With more than one table, every column needs a table qualifier. Every table is a label, so a node without a label has no SQL form. A table named Start_TYPE_End, such as Person_ACTED_IN_Movie, is a relationship table. On its own it matches (_start:Person)-[person_acted_in_movie:ACTED_IN]->(_end:Movie). Its unqualified columns are relationship properties, Person.name or _start.name is a property of the start node, Movie.title or _end.title of the end node, and person_name is shorthand for _start.name when only the start label is Person, in reads, UPDATE, and INSERT alike; qualify a relationship property whose name looks like shorthand with the type, as in ACTED_IN.person_count. The start and end each need a lowercase letter, so upper-case table names such as ORDER_LINE_ITEMS stay node labels. When a FROM names several relationship tables, each one’s nodes are <alias>_start and <alias>_end instead.

Joins

Joins become relationship patterns by these rules:
  • In a direct join, the second column of the = names the relationship type, uppercased, and the relationship points away from the table it belongs to. Its variable is the lowercased type, numbered when a table already uses it, and columns can read it, as in directed.since.
  • In a chain of three tables, the middle one is the relationship; longer chains continue in pairs, and the last ON of each pair sets the direction.
  • Every ON must compare two columns with =. Put other conditions in WHERE. The compared columns only name the relationship and its direction; they are not read.

Mappings

mappings renames tables and join columns. Keys match case-insensitively:
This matches (p:Person)-[directed:DIRECTED]->(m:Movie). A join_columns key names the join’s left table and the column that names the type. The SDKs take the same mappings: helix_db::SqlMappings in Rust, { tables, joinColumns } in TypeScript, helix.SQLMappings in Go, and the mappings= dictionary in Python.

Statements

Grouping follows SQL: a selected column must be grouped or aggregated, GROUP BY without an aggregate returns each group once, an aggregate without GROUP BY returns one row even for an empty table, SUM of no values is null, and ORDER BY 1 or GROUP BY 1 refer to select-list positions. An alias in GROUP BY, HAVING, or ORDER BY names its select-list entry.
An INSERT into a relationship table connects existing nodes. Columns of the start and end nodes find them, and the other columns become relationship properties:
which runs as:
It connects every pair of nodes the columns match, and creates nothing when either node is missing. To connect nodes by id, use _start.v$id and _end.v$id, or v$person_id when the label tells the ends apart.

Expressions and functions

Comparisons, AND, OR, NOT, IS [NOT] NULL, IS [NOT] TRUE, IS [NOT] DISTINCT FROM, IN lists, BETWEEN, arithmetic, CASE, CAST, and row values such as (a, b) < (1, 2) translate with SQL semantics. || and CONCAT join their operands as strings; || returns null when an operand is null, and CONCAT skips nulls, as in PostgreSQL. CAST uses Cypher’s conversions: to an integer type it truncates toward zero, and text that does not convert becomes null rather than an error. LIKE and ILIKE translate when the pattern is a literal whose only wildcards are a leading or trailing %: '%x%' becomes CONTAINS, 'x%' STARTS WITH, and '%x' ENDS WITH. Functions with a Cypher equivalent are renamed: lower and upper become toLower and toUpper, length and char_length become size, nvl and ifnull become coalesce, array_agg becomes collect (which, unlike SQL’s array_agg, leaves out nulls), and nullif and nvl2 become CASE. SUBSTRING counts from 1, as in SQL; a start below 1 must be a literal, since a computed one fails. TRIM, LTRIM, and RTRIM remove all surrounding whitespace, as Cypher’s do, not only spaces. Other names pass through, so the Cypher profile decides which functions run.

What is not supported

These are rejected before anything runs, mostly with the error category unsupported_feature and the detail sql_construct: Some fail as ordinary compile errors instead:
  • An unqualified column in a statement with several tables is a syntax_error (ambiguous_column), as is a qualifier that names several tables.
  • A selected column that is neither grouped nor aggregated is a syntax_error (invalid_aggregation).
  • A function outside the Cypher profile, such as sqrt, reports that profile’s error.
Nodes without a label, variable-length paths, and vector and full-text search have no SQL form; use Cypher or native queries for them.

Values and parameters

Values in parameters and results use the Cypher encoding: ordinary JSON for most values, tagged values for large integers, non-finite floats, nodes, relationships, and paths. A statement that uses ? cannot also use numbered placeholders, since ? takes those numbers. IN (?) binds a list parameter. Labels, relationship types, and column names are part of the statement, so keep values in parameters.

Writes and transactions

Each statement that writes runs in one transaction, including its index changes, as a Cypher write does. If any part fails, nothing the statement wrote is kept.
  • A multi-row INSERT creates every row or none. Inserting into a relationship table that matches no node pair creates nothing and succeeds.
  • UPDATE computes every new value from the row before the update, so SET a = b, b = a swaps. Setting a column to NULL removes the property.
  • DELETE or TRUNCATE of a node that still has relationships fails, like a foreign key. Delete the relationships first, or use TRUNCATE .. CASCADE, which removes them.
  • Stored properties follow the Cypher rules: scalars or lists of one scalar type, and values an index can store.
  • One request is one statement, so a write followed by a read is two requests and two transactions.
  • Do not retry a write automatically when its commit outcome is uncertain; see error handling.

Indexes and performance

SQL runs as the translated Cypher, so it uses existing native indexes the same way, without naming them. The Cypher rules apply to the translated statement:
  • An equality on a table’s column, such as WHERE u.email = ?, can read an equality index, and so can IN lists and ORed equalities on one column.
  • A range such as WHERE u.age >= 21 AND u.age < ? can read a range index.
  • WHERE id(u) = ?, or WHERE u.v$id IN (1, 2) with a list of literal ids, reads those nodes directly. A list parameter, as in IN (?), scans the label.
  • A join reads only the relationship type it names, so name the relationship table or join column rather than joining every relationship.
  • Without DISTINCT or grouping, ORDER BY, OFFSET, and LIMIT apply to the matched tables before the select list reads columns, so SELECT u.name FROM User u LIMIT 10 stops after ten nodes when its WHERE cannot fail.
  • ORDER BY a range-indexed column in the index’s direction, with a range bound on it and a LIMIT, reads the index in order and stops after the window, as in SELECT u.name FROM User u WHERE u.rank >= 0 ORDER BY u.rank DESC LIMIT 10 with a descending index on rank.
An index is only used when every other condition on that table cannot fail. Comparisons, string predicates, null tests, and their AND, OR, XOR, and NOT combinations over literals, parameters, and columns qualify. Arithmetic and function calls keep the label scan.

Limits

A SQL statement has the Cypher limits, plus 1,024 binary operators and about 500,000 tokens, where every word, number, symbol, and whitespace character counts, including those inside quoted text. Forms that repeat an operand, such as BETWEEN or an alias in GROUP BY, may copy about 16 MB of it in total. Exceeding any of these fails with resource_limit (HTTP 429). Send large values as parameters, which do not count.

Errors

A failed statement returns the Cypher error body. Translation errors and the errors of the translated statement both report a span, the byte range of the SQL text that failed:
The error reference lists every category with its HTTP status and retry guidance.

Next steps

Cypher

The Cypher profile that SQL translates to, and how it plans indexes.

HTTP API

Request headers, status codes, and the OpenAPI contract for /v2/sql.

Error handling

Error categories, HTTP statuses, and when to retry.

Secondary indexes

Create the indexes that SQL and Cypher plans read.