Skip to content

The quoting mistake that returns zero rows and no error

A double-quoted identifier is a string literal here, so a filter on one compares two literals and answers nothing with a perfectly successful response.

Write WHERE "customer" = 'acme' against an OpenSearch cluster and you get HTTP 200, an empty result grid, and no indication that anything went wrong. The statement parsed. The index was found. The engine did exactly what you asked and what you asked was meaningless. OpenSearch SQL identifier quoting uses the backtick, and the double quote you reached for out of habit built a string literal instead.

An error is a message; silence is a wrong answer wearing the costume of a right one.

Two quoting characters with opposite meanings

Measured on OpenSearch 3.8.0, image opensearchproject/opensearch:3.8.0, a stock single node with the security plugin disabled:

SELECT customer FROM "probe_orders"

answers HTTP 404, IndexNotFoundException, no such index ["probe_orders"] — quotes included in the name it looked for. That one is fine. It fails loudly and the message contains the evidence: the double quotes are inside the string the engine searched for.

The dangerous one is the mirror image:

SELECT "customer" FROM probe_orders

which answers HTTP 200 with

{"schema":[{"name":"\"customer\"","type":"keyword"}],"datarows":[["customer"]]}

One column, and every row carries the string customer itself. The field of that name was never read. The backtick form is the one that reaches a field:

SELECT customer FROM `probe_orders`

is HTTP 200 with the documents. Square brackets are an identifier quote here too — SELECT [customer] FROM probe_orders returns the field’s value, while SELECT [1, 2] is refused with All items between Brackets should be identifiers, got:LITERAL_INT. So this grammar has three quoting characters, and the double quote is the only one of the three that does not name a field.

The filter that compares two literals

Projection is where you notice. A predicate is where it costs you.

SELECT * FROM probe_orders WHERE "customer" = 'acme'

Both sides of that comparison are constants. The left is the literal string customer, the right is the literal string acme, and they are not equal, so the predicate is false for every document in the index. The engine answers zero rows, HTTP 200, no warning.

A double-quoted identifier is a string literal on this engine, so a filter written that way answers zero rows with a successful response and no error at all. Backticks are the identifier form. Nothing in the response distinguishes that outcome from an index that genuinely holds no matching document, and nothing can, because from the engine’s position the statement was valid and its answer was correct.

The reverse is just as quiet. WHERE "customer" = "customer" is true for every document, and a filter you believed you had applied is not applied at all.

Why the response is a success

It helps to see why the cluster cannot warn you. The SQL surface here is a bundled plugin, POST /_plugins/_sql, and each statement is one stateless HTTP request with a JSON body. The success envelope is schema and datarows with total and size beside them. There is a status member in the body, and it duplicates the HTTP status.

Nothing in that envelope has a place to put “your predicate compared two constants”. A comparison of two literals is a legal expression in every SQL dialect that has literals. The engine is not being permissive; it is answering the question it was handed.

Compare that with the failures this engine does report, which are specific. A misspelled field is SemanticCheckException with the text can't resolve Symbol(namespace=FIELD_NAME, name=nosuchfield) in type env. A missing index is IndexNotFoundException. A mistyped keyword is SQLFeatureNotSupportedException with Query must start with SELECT, DELETE, SHOW or DESCRIBE. Every one of those is a diagnosis you can act on. The quoting mistake produces none of them, so a user who hits it goes looking for missing documents rather than for a mistyped quote.

One more detail worth carrying: the useful sentence is in the response’s details member, not in reason. Measured, reason is the constant string Invalid SQL query for a mistyped keyword, an unknown column and an unparseable LIMIT alike. LibreDB Studio reads details first and carries the engine’s own wording through verbatim, stripping only the trailing footer that tells you to re-send the request in another format to see the raw engine response — advice about the REST API rather than about your statement.

What the editor itself emits

The provider does not build much SQL on its own. The schema tree comes from the index mapping rather than from a statement — GET /_cat/indices for the indices and GET /<index>/_mapping for the fields, flattened to dotted paths — so there is no generated SELECT doing introspection for you to inherit a quoting bug from.

Where the product does quote, it quotes with a backtick. The opensearch type-id shares MySQL’s branch in the codebase’s quoter (src/lib/sql/identifier.ts), and the sibling search type-id, served by the same directory, cannot share that branch: the quoting rule is a per-product fact, not a family one. Index names make this concrete. A stock cluster already carries top_queries-2026.08.18-74305, an engine-managed index whose name contains both hyphens and dots, and SQL will not read it unquoted. The inventory records the name verbatim and quoting belongs to whoever builds the statement, which on this connection means a backtick.

Which quote a provider uses is data the interface reads rather than a branch someone maintains, the same shape as every other declared capability; each engine’s published boundary sits on its row on the databases page.

Paging, and why it is expressed as data rather than a branch

The good news in the same grammar. Measured:

SELECT customer FROM probe_orders LIMIT 2 OFFSET 1

is HTTP 200 with the rows the offset asks for. Both LIMIT n and LIMIT n OFFSET m are correct here, so the shared limiter that wraps a user’s statement is right unmodified and the query preparer refuses nothing on this connection. Load more works on an OpenSearch connection.

The way that is expressed matters more than the fact. One directory serves two search type-ids, and OFFSET is the single behavioural difference between them above the wire. It is declared as a per-product trait, acceptsOffsetClause: true, read by prepareQuery() alone. A method that asked whether the dialect happened to be OpenSearch would work and would be the wrong shape: it states the product rather than the capability, and the next engine gets added to a condition someone has to find rather than declaring its own answer.

Two facts about the same grammar, then, and they are not opposites. The paging clause is a trait the code can read and act on. The quoting rule is not: nothing in the transport can inspect your predicate and decide you meant a field. The only defence is the backtick, typed by hand.