This file holds what is known about Apache Druid, for the driver druid
(W25, D154 and D162). The headings are the template of
DRIVER.md.
Steps 5a and 6 measured 36.0.0 and 37.0.0 on 2026-10-01 and 2026-10-02, as
admin, the administrator, and as dbmeta_user, an ordinary user who can
read every datasource. A fact marked "recorded" is in testdata/druid/, and
the name in quotes after it is the name of its request in requests.json
there. The two releases gave the same answers, except where a line says
otherwise. A request whose name starts with step 7: ran on 36.0.0 only. A
fact marked "measured" names how it was measured. Each section starts with
the measured facts. The facts after them come from the sources of step 3,
and each one that is not measured says so. The sources, each read on
2026-10-01, are these:
- "The dbmeta entry" is
container/druid.goindbmeta. It starts the imagedocker.io/apache/druidwith ZooKeeper and the five services of the nano quickstart in one container, and the basic security extension. The administrator isadmin. ItsInitmakes the userdbmeta_userwith the roledbmeta_role, which canREADevery datasource and nothing else. - "godruid" is
github.com/IfanTsai/godruidat commit64f1a49of 2026-09-12, a Go client with adatabase/sqldriver indruidsql/. - "kaplanmaxe" is
github.com/kaplanmaxe/go-druidat commit1d99314of 2020-01-05, a Godatabase/sqldriver indsql/. - "pydruid" is
github.com/druid-io/pydruidat commit735ba4aof 2026-05-04, the Python client of the Druid project, and its DB-API modulepydruid/db/api.py. - "Gemini" is
gemini-3.8-flash, and "DeepSeek" isdeepseek-flash, asked on 2026-10-01 and 2026-10-02.
- Apache Druid is a column store for analytics. A table is a datasource,
and each row has the timestamp
__time. Druid stores a datasource in segments, and each segment holds one span of time. - R holds.
dbrunstartsdruid-36.0.0anddruid-37.0.0, in the Staged tier with the cadencetested(the dbmeta entry, dbmeta D113). Each one uses up to 2.7 GB (dbmeta D113).GET /statusgave the version of each (recorded: "the version"). - H and S hold.
POST /druid/v2/sqlon the Router took SQL and answered with HTTP 200 as both principals on each release (recorded: "a statement"). - The SQL API reads only.
INSERTandREPLACErun as tasks of the multi-stage engine throughPOST /druid/v2/sql/task.UPDATEandDELETEfail on both APIs.REPLACEof a span of time replaces the rows of that span, and aREPLACEwith no rows removes them (recorded: "crud: insert", "crud: update", "crud: replace one day", "crud: delete" and "crud: delete one day with replace"). DRIVER.md says to stop and ask Ken when a server refuses one of insert, select, update and delete. See the open questions. - The priority is in TARGETS.md: Druid is number 10 of the
order (D162). D154 gives it a driver of its own, and not a flavor of
Avatica, although Druid also speaks Avatica at
/druid/v2/sql/avatica/. dburlhas no scheme for Druid, andusqlhas no driver for it (measured withgrepindburl/scheme.goandls usql/driverson 2026-10-01).dbmetahas no model for Druid. Its backlog waits for this driver and a scheme indburl(dbmeta/docs/BACKLOG.md).- The driver is
github.com/xo/dbimp/druid(D164). It reads only (D163). Its integration tests passed ondruid-36.0.0and ondruid-37.0.0on 2026-10-02, as both principals (step 14).
These facts were recorded on each release:
- A statement is
POST /druid/v2/sqlwithContent-Type: application/jsonand a JSON object.queryholds the text. The other fields are optional:resultFormat,header,typesHeader,sqlTypesHeader,contextandparameters(recorded: "a statement"). - A request with no
Content-Typefails with HTTP 400 andMissing Content-Type header(recorded: "a statement with no content type"). A body oftext/plainis the SQL text itself, and the answer has the default form (recorded: "a statement as plain text").GET /druid/v2/sqlanswers HTTP 405 (recorded: "a GET of the SQL API"). - A field that the server does not know is ignored, in the body and in the
context (recorded: "an unknown field in the body" and "feature: an unknown
context key"). An unknown
resultFormatfails with HTTP 400 (recorded: "an unknown result format"). contextholds the parameters of one query, such assqlQueryId,sqlTimeZone,sqlStringifyArrays,sqlOuterLimitandtimeout(recorded: "feature: a query id", "feature: the time zone in the context", "every type with arrays as arrays", "a result with an outer limit" and "a timeout in the context"). ASETstatement before the query sets the same keys (recorded: "feature: SET before a statement").- The answer names the query in
X-Druid-SQL-Query-IdandX-Druid-Query-Id. WithsqlQueryIdin the context, both hold that id (recorded: "feature: a query id").X-Druid-Response-Contextholds a JSON object, such as{"missingSegments":[]}. - A write is
POST /druid/v2/sql/taskwith the same body. It answers HTTP 202 withtaskIdandstateRUNNING(recorded: "crud: insert").GET /druid/indexer/v1/task/<taskId>/statusgivesstatusCodeSUCCESSwhen the task ends (recorded: "crud: the state of the insert task"). The rows of the task answer a query some seconds after it ends: a select 60 seconds after the task showed them (recorded: "crud: select"). - A statement that the task API refuses before it runs answers HTTP 400
with
taskId,stateFAILEDanderror(recorded: "crud: update through the task API" and "an error of a task"). - The SQL API refuses
INSERTwithINSERT operations are not supported by requested SQL engine [native], consider using MSQ.(recorded: "crud: insert through the SQL API"). - Authentication is HTTP Basic. A wrong password answers HTTP 401 with a page of HTML (recorded: "a wrong password").
- The Router passes
/druid/coordinator/...,/druid/indexer/...and/proxy/coordinator/...to the Coordinator and the Overlord (recorded: "the datasources of the Coordinator", "crud: the state of the insert task" and "the users of the security API").
This fact was measured with curl on 37.0.0 on 2026-10-01:
- Two tasks of the multi-stage engine that started at the same time never
ended, and four more waited behind them. Each task needs a slot for its
controller and one for its worker, and the nano quickstart has two slots.
POST /druid/indexer/v1/task/<id>/shutdownended them. So the setup ofrequests.jsonwaits 40 seconds between two tasks.
- The DSN is
druid://user:password@host:8888, with the keystls,timezoneandtimeout, and no path (D164). The port is 8888 when the URL names none, withtls=truetoo. - godruid takes the schemes
druid,druids,httpandhttps, and the keysheader,token,jwt,skip_tls_verify,ca_cert,client_cert,client_key,proxy,userandpassword(godruid,druidsql/dsn.go). kaplanmaxe takes the address of the Broker, and a path for its ping (kaplanmaxe,dsn.go). dbrungives each principal ashttp://user:password@127.0.0.1:<port>, the address of the Router, which listens on 8888 in the container (the dbmeta entry).- Druid has no database to choose. Every datasource is in the schema
druid, and the other schemas aresys,INFORMATION_SCHEMAandlookup(recorded: "schema: the datasources" and "feature: lookup table").
These facts were recorded on each release:
- The answer is HTTP 200 with
Content-Type: application/json, for every result format,csvtoo (recorded: "a statement" and "the result format csv"). resultFormatisobjectby default: one JSON array of objects, each with the name of each column (recorded: "a statement with no options").arrayis one JSON array of arrays, in the order of the columns.objectLinesandarrayLineswrite one row on each line, and end with an empty line.csvwrites one row on each line, with quotes where they are needed, and ends with an empty line (recorded: "the result format objectLines", "the result format arrayLines" and "the result format csv").tsvis not a format (recorded: "an unknown result format").- With
header, the first row holds the names of the columns. WithtypesHeaderas well, the next row holds the native type of each column, such asLONGorARRAY<STRING>. WithsqlTypesHeader, the next row holds the SQL type, such asBIGINTorARRAY(recorded: "a statement" and "only the names of the columns"). InobjectandobjectLines, the first object maps each name to{"type": ..., "sqlType": ...}(recorded: "the result format object").X-Druid-SQL-Header-Included: yessays that the header is there. - So the names and the types of the columns arrive before the first row, in the order of the statement. A result with no rows still has its header (recorded: "the columns of an empty result", "the columns of an empty result in lines" and "the columns of a limit of zero").
- Two columns with one name keep both in
array. Inobject, the object holds the name twice, as{"a":1,"a":9223372036854775807}(recorded: "two columns with one name as arrays" and "two columns with one name as objects"). - A result of 400 rows came whole in one answer, in the order of
__time(recorded: "a result of 400 rows" and "a result of 400 rows in lines"). The SQL API does not page.sqlOuterLimitcuts the result at that many rows, with no sign in the answer, andLIMITwithOFFSETworks (recorded: "a result with an outer limit" and "a result with limit and offset"). ORDER BYon a column other than__time, with noGROUP BY, fails withQuery could not be planned(measured with the recorder on 37.0.0 on 2026-10-01, in a run that a later run replaced).- On 37.0.0 the datasource
dbimp_tis in the answers too. A probe withcurlmade it before the recordings (recorded: "schema: the datasources"). - With
Accept-Encoding: gzip, the answer hasContent-Encoding: gzip(recorded: "a gzip answer"). - No answer redirects.
GET /druid/coordinator/v1/leaderthrough the Router gives the address of the Coordinator inside the container,http://localhost:8081(recorded: "a path of the Coordinator through the Router").
These facts were recorded as step 7: requests on 36.0.0:
POST /druid/v2/sql/statementswithexecutionModeASYNCin the context runs a query as a task. It answers HTTP 200 withqueryId,stateACCEPTEDand theschemaof the result (recorded: "step 7: a statement of the statements API").GETof the statement gives its state and its pages, andGET .../results?page=0gives the rows (recorded: "step 7: the state of the statement" and "step 7: the first page of the statement").- Without durable storage, the result is one page whatever
rowsPerPagesays, and the next page fails withPage number [1] is out of the range of results.selectDestinationdurableStoragefails, because the server has no durable storage (recorded: "step 7: the second page of the statement" and "step 7: a statement that asks for durable storage").
These facts come from the sources, and are not measured:
- pydruid reads the answer as it arrives, in chunks, and godruid reads the
rows one at a time (pydruid and godruid). kaplanmaxe reads the whole body
with
ioutil.ReadAll(kaplanmaxe,connection.go). - Without durable storage, the statements API keeps at most 3,000 rows (Gemini).
The column Kind names the kind of each type in TYPES.md, which
maps every kind onto its Go type (D135 and D137). Step 8a wrote this table
for Ken to review. The wire type is the SQL type of sqlTypesHeader, except
for OTHER, which the native type of typesHeader names.
| Wire type | Kind | Go type | Scan type | Database type | Can be NULL |
|---|---|---|---|---|---|
| BIGINT | integer | int64 |
int64 |
BIGINT |
yes |
| INTEGER | integer | int64 |
int64 |
INTEGER |
yes |
| FLOAT | float | float64, from the JSON number of a float32 |
float64 |
FLOAT |
yes |
| REAL | float | float64 |
float64 |
REAL |
yes |
| DOUBLE | float | float64, with the infinities and NaN from their strings |
float64 |
DOUBLE |
yes |
| DECIMAL | decimal | float64, because the server computes it as a double (D164) |
float64 |
DECIMAL |
yes |
| BOOLEAN | boolean | bool |
bool |
BOOLEAN |
yes |
| VARCHAR | string | string. A multi-value string with more than one value is a []any of its strings (D164) |
string |
VARCHAR |
yes |
| CHAR | string | string |
string |
CHAR |
yes |
| TIMESTAMP | timestamp | time.Time, from the ISO 8601 text with the offset of sqlTimeZone |
time.Time |
TIMESTAMP |
yes |
| DATE | date | dbimp.Date, from the date of the ISO 8601 text of its midnight in sqlTimeZone |
dbimp.Date |
DATE |
yes |
| ARRAY | array | []any, of the Go types of the elements that the native type names, from the JSON text or the JSON array |
[]interface {} |
ARRAY |
yes |
| COMPLEX | json | the decoded JSON value: nil, bool, string, int64, float64, *apd.Decimal, []any or map[string]any, from the JSON text in a string |
interface {} |
OTHER |
yes |
| COMPLEX | binary | []byte, from the base64 in the JSON text of a string |
[]uint8 |
OTHER |
yes |
| NULL | null | nil |
interface {} |
NULL |
yes |
These facts were recorded on each release, from the datasource
dbimp_types with three rows: one with values, one with empty or small
values, and one of NULL (recorded: "every type", unless a line says
otherwise):
- A NULL is the JSON
nullin every type and every JSON format. Incsv, a NULL and an empty string are both an empty field (recorded: "every type as csv"). - A
BIGINTis a JSON number, and keeps every digit from -9223372036854775808 to 9223372036854775807. A literal outside that range fails withNumeric literal ... out of range(recorded: "an integer literal out of range"). CAST(... AS INTEGER)givesINTEGERwith the native typeLONG.CAST(... AS SMALLINT)andCAST(... AS TINYINT)fail withCannot coerce field [id] from type [java.lang.Integer] to type [SMALLINT](recorded: "casts to other types", "a smallint" and "a tinyint").- A
FLOATcolumn holds a float32:3.4e38reads back as3.4E38, and as3.3999999521443642E38when cast toDOUBLE(recorded: "a float near its limit").REALis aDOUBLEin the native type. - A
DOUBLEis a JSON number, and an infinity or NaN is the string"Infinity","-Infinity"or"NaN"(recorded: "an infinity and NaN").CAST('NaN' AS DOUBLE)fails (measured withcurlon 37.0.0 on 2026-10-01). - A
DECIMALhas the native typeDOUBLE.CAST(d AS DECIMAL(38, 10))of1.7976931348623157e308reads1.7976931348623157E308, and the literal1234567890.123reads1.234567890123E9(recorded: "casts to other types", and measured withcurlon 37.0.0 on 2026-10-01). - A
BOOLEANof the query, such asid = 1, is a JSON boolean with the native typeLONG. A boolean written to a datasource is stored as aBIGINTof 1 or 0 (recorded: "a boolean column of the query" and "every type"). - A
VARCHARis a JSON string, with every character, and an empty string stays empty (recorded). A literal string isCHAR(recorded: "literals of each type"). - A multi-value string has the SQL type
VARCHARand the native typeSTRING. One value is that string, and two values are the JSON text of an array of strings,"[\"x\",\"y\"]", whateversqlStringifyArrayssays.GROUP BYgives one row for each value (recorded: "feature: multi-value strings" and "feature: a multi-value string grouped").MV_TO_ARRAYgives a JSON array of strings (recorded: "step 7: a multi-value string as an array"). - A
TIMESTAMPis ISO 8601 text with milliseconds, such as"2026-10-01T12:34:56.789Z". WithsqlTimeZoneAsia/Jakarta, it is"2026-10-01T19:34:56.789+07:00"(recorded: "feature: the time zone in the context").0001-01-01T00:00:00.000Zand1900-01-01T00:00:00.000Zread back as written (recorded: "a timestamp before 1970"). - A
DATEis the ISO 8601 text of its midnight, such as"2026-10-01T00:00:00.000Z", or"2026-10-01T00:00:00.000+07:00"withsqlTimeZone(recorded: "casts to other types" and "feature: the time zone in the context"). - An
ARRAYhas the native typeARRAY<STRING>,ARRAY<LONG>orARRAY<DOUBLE>. By default it is the JSON text of the array, such as"[\"a\",null,\"c\"]". WithsqlStringifyArraysfalse, it is a JSON array (recorded: "every type" and "every type with arrays as arrays"). An element can be NULL.ARRAY[]fails withRequire at least 1 argument(measured withcurlon 37.0.0 on 2026-10-01). - A JSON column has the SQL type
OTHERand the native typeCOMPLEX<json>. It is the JSON text in a string, such as"{\"k\":[1,\"two\",null,{\"n\":1.5}]}", withsqlStringifyArraystrue or false (recorded: "every type" and "every type with arrays as arrays"). - An HLL sketch has the SQL type
OTHERand the native typeCOMPLEX<HLLSketch>. It is the JSON text of a string of base64, such as"\"AgEHDAMIAQD2fr0F\""(recorded: "feature: a sketch"). - A NULL literal has the SQL type
NULLand the native typeSTRING(recorded: "literals of each type" and "typed parameters"). TIMEfails withUnsupported DateTime type[TIME], andVARBINARYfails withUnhandled Query Planning Failure(recorded: "a time of day" and "a binary value").
These facts come from the sources, and are not measured:
- godruid gives a
TIMESTAMPand aDATEas astring, and an array or a JSON value as its JSON text in a[]byte(godruid,druidsql/rows.go). - Other sketches, such as a theta sketch, arrive as base64 too (Gemini).
These facts were recorded on each release:
- A statement names each parameter as
?.parametersis a list of{"type": ..., "value": ...}, in the order of the marks. A type is a SQL type, such asVARCHAR,BIGINT,DOUBLE,BOOLEAN,TIMESTAMP,DATE,DECIMALorARRAY(recorded: "typed parameters" and "an array parameter"). - A
TIMESTAMPtakes its text, such as2026-10-01 12:34:56.789, or its milliseconds since 1970-01-01 (recorded: "a timestamp parameter in milliseconds"). ADATEtakes its text (recorded: "a date parameter"). - A
VARCHARofnullbinds NULL (recorded: "typed parameters"). - Too few values fail with
No value bound for parameter (position [2]). Too many are ignored (recorded: "too few parameters" and "too many parameters"). - The
INTEGER"1", a string, fails with HTTP 500 andCannot handle query. A parameter with notypefails with HTTP 400 andNullPointerException(recorded: "a string for an integer" and "a parameter with no type"). - A
DECIMALbinds as the type of its JSON number: the value1.2345678901234568e18came back as theBIGINT1234567890123456768 (recorded: "a decimal parameter"). :idis a syntax error. A?inside a quoted string is text, and not a parameter (recorded: "a named parameter" and "a parameter in a quoted string").- A
?whose type the statement cannot infer fails withIllegal use of dynamic parameter(measured withcurlon 37.0.0 on 2026-10-01).
These facts come from the sources, and are not measured:
- godruid sends each argument as a parameter, with
VARCHARfor nil and for[]byte,BIGINTfor an integer,DOUBLEfor a float andTIMESTAMPin milliseconds for atime.Time. It refuses a named argument (godruid,sqlparam.goanddruidsql/conn.go). - pydruid writes each argument into the text as a literal, with
'doubled in a string (pydruid). kaplanmaxe ignores the arguments (kaplanmaxe).
- Druid has no transactions.
BEGINandCOMMITfail with a syntax error (recorded: "begin a transaction" and "commit a transaction"). - A task replaces whole segments, so a write of one span of time is atomic (Gemini, not measured).
These facts were recorded on each release:
- An error before any rows answers with a JSON object:
error,errorCode,persona,category,errorMessageandcontext. A syntax error and an unknown datasource are HTTP 400 withcategoryINVALID_INPUT(recorded: "a syntax error" and "an unknown datasource"). - An error while the query runs, such as a division of a
BIGINTby zero, is HTTP 500 witherrorClassjava.lang.ArithmeticExceptionandcategoryRUNTIME_FAILURE. ItserrorMessageis/ by zeroon 36.0.0 andnullon 37.0.0 (recorded: "an error before any rows"). A query that the server cannot plan is HTTP 400 or HTTP 501 (recorded: "a binary value"). - A query that passes its
timeoutis HTTP 504 withcategoryTIMEOUT(recorded: "a timeout in the context"). - An error after some rows ends a response of HTTP 200 with no sign of the
error. The query divided by zero in the last row, which is in a second
segment. In
array, the body ends after row 397 with no closing]. InarrayLinesandobjectLines, it ends with no empty line. Rows 398 and 399 never arrived, and the connection ended in the normal way (recorded: "an error after some rows", "an error after some rows in lines" and "an error after some rows as objects"). - A wrong password is HTTP 401 with a page of HTML. A refused privilege is
HTTP 403 with
{"Access-Check-Result": ...}(recorded: "a wrong password" and "a system table that needs a privilege"). - No request answered HTTP 429.
These facts were recorded on each release:
DELETE /druid/v2/sql/<sqlQueryId>cancels a running query, and answers HTTP 202. The query then answers HTTP 500 withSequence canceled. An id that is not running answers HTTP 404 (recorded: "a statement to cancel", "cancel the statement by its id" and "cancel a statement that is not running"). The ordinary user cancelled its own query.timeoutin the context, in milliseconds, stops the query on the server (recorded: "a timeout in the context").- A statement after a client left answered at once (recorded: "a statement after the client left").
These facts were measured with curl and the processor time of the
container in /sys/fs/cgroup/cpu.stat, on 37.0.0 on 2026-10-01:
- A query that the client left after 2 seconds used about 2.3 seconds of
processor time in each 2 seconds until its
timeout, 8 seconds later. So the server does not stop a query when the client leaves. - After
DELETE /druid/v2/sql/<sqlQueryId>, the processor time dropped to the idle level at once. - As
dbmeta_user,DELETEof the query ofadminanswered HTTP 404.
These facts come from the sources, and are not measured:
- The server notices a client that left only when it writes to the socket (Gemini).
- godruid cancels a query of the statements API with
DELETE /druid/v2/sql/statements/<id>(godruid,statement.go).
These facts were recorded on each release:
- Two statements in one request fail:
Only SET statements can appear before the final statement in a statement list.SETstatements before the last one set keys of the context (recorded: "two statements in one request" and "feature: SET before a statement"). - Comments
--and/* */are part of the text, and a statement can end with a semicolon (recorded: "comments" and "a statement that ends with a semicolon"). EXPLAIN PLAN FORgives the native query as JSON text in one row (recorded: "feature: explain").UPSERTfails withUPSERT is not supported.on the task API (recorded: "step 7: upsert through the task API").CREATE TABLE,CREATE INDEX,CREATE VIEW,ALTER TABLEandDROP TABLEfail with a syntax error, which namesINSERT,UPSERT,EXPLAIN,SETandRESETamong the statements that it expects (recorded: "schema: create table", "schema: create index", "schema: create view", "schema: alter table" and "schema: drop table"). A datasource is made by its first write, andDELETE /druid/coordinator/v1/datasources/<name>drops it (recorded: "teardown: drop the datasource for crud").
These facts were measured by the integration tests of the driver, on 36.0.0 and 37.0.0 on 2026-10-02:
- A task of the multi-stage engine took from 5 to 12 seconds, and its rows
answered a query some seconds after it ended. A dropped datasource left
the answers in about one second (
TestIntegrationSchema). POST /druid/coordinator/v1/datasources/<name>/markUnusedwith an interval drops the rows of that interval from the answers, with no task (TestIntegrationRoundTrip).CLUSTERED BY idwrites a segment of the shard specnumbered, whose rows are in the order of__timeand then ofid, and a query with noORDER BYreads them in that order (TestIntegrationSchema).- A segment whose JSON column holds only NULL stores the column as a
VARCHAR. A datasource whose only segment is such a segment answers the column as aVARCHAR(TestIntegrationRoundTrip). ORDER BYon a column other than__time, with noGROUP BY, fails withQuery could not be planned, on a lookup table too (TestIntegrationFeatures).- A lookup that the Coordinator makes answers
LOOKUPwithin about 60 seconds (TestIntegrationFeatures). - The task API refuses
CAST(NULL AS VARCHAR ARRAY)withCannot handle literal [null:VARCHAR NOT NULL ARRAY] of unsupported type [ARRAY]. A NULL array thatEXTERNreads from a row of JSON is written (TestIntegrationRoundTrip, on 36.0.0). - When the host was under heavy load, the Router answered HTTP 503 with
Unable to determine destinationfor the state of a task, and the task API refused a task with HTTP 500 that names HTTP 503 of the Overlord. The tests read the state again, and submit such a task again (on 36.0.0).
These facts were recorded on each release:
dbmeta_userreads every datasource,INFORMATION_SCHEMA,sys.segments,sys.tasksand the lookups (recorded asdbmeta_user).dbmeta_useris refusedsys.servers,GET /status, the security API, the leader of the Coordinator, and every task, with HTTP 403 (recorded asdbmeta_user: "a system table that needs a privilege", "the version", "the users of the security API", "a path of the Coordinator through the Router" and "crud: insert").- So
dbmeta_usercannot read the version.adminreads it fromGET /statusand fromSELECT server_type, version FROM sys.servers(recorded: "the version" and "the version of each service"). dbmeta_userreadsGET /druid/coordinator/v1/datasources(recorded: "the datasources of the Coordinator").- The driver does not answer
SELECT version()itself (D181). Druid has a query for the release,SELECT server_type, version FROM sys.servers, and D181 gives no support to a product that has one. Ondruid-38.0.0on 2026-10-08,SELECT version()andSELECT DRUID_VERSION()failed withNo match found for function signature, andsys.serversgave38.0.0toadminand HTTP 403 todbmeta_user.GET /statusgave38.0.0toadminand HTTP 403 todbmeta_user. The driver sends every statement to the server, so the ordinary user gets the error of the server.
Step 16 ran the statement for the version through the driver, on 36.0.0 and
37.0.0 on 2026-10-02 (TestIntegrationVersion). usql has no driver for
Druid, so the statement is SELECT server_type, version FROM sys.servers,
which answers for each service:
adminreads one row for each service,broker,coordinator,historical,middle_manager,overlordandrouter, each with the version of the release, such as36.0.0.dbmeta_usergets HTTP 403 withInsufficient permission to view servers: Unauthorized. So the ordinary user gets no version.
- No flavor was measured.
- Imply Enterprise and Imply Polaris serve the same SQL API, and Polaris adds its own authentication (Gemini, not measured).
druid/tables_test.go writes this table from the code (step 10).
| Interface | Implemented | Reason |
|---|---|---|
driver.DriverContext |
yes | OpenConnector parses the DSN once, for every connection. |
driver.Connector |
yes | The connector owns the transport, which every connection shares. |
io.Closer on the connector |
yes | Close closes the idle connections of the transport. |
driver.Pinger |
yes | Ping runs SELECT 1, which checks the credentials. GET /status needs more than the privilege to read. |
driver.SessionResetter |
no | A connection holds nothing on the server, because the SQL API has no sessions. |
driver.Validator |
no | A connection holds nothing on the server, so it is always valid. |
driver.NamedValueChecker |
yes | It keeps an Option, a decimal, a dbimp.Date, a dbimp.LocalDateTime and a slice, which the driver binds with a type of their own (D164). |
driver.QueryerContext |
yes | The server binds each argument as a typed parameter (D164). |
driver.ExecerContext |
yes | Exec reads the result to its end. The SQL API takes no write, so RowsAffected fails (D163). |
driver.ConnPrepareContext |
yes | A prepared statement runs as its text, with its arguments, each time. |
driver.ConnBeginTx |
yes | BeginTx fails with dbimp.ErrNotSupported, because Druid has no transactions (D164). |
driver.RowsColumnScanner |
yes | A value is decoded when its row is read, and assigned when it is scanned. |
driver.RowsNextResultSet |
no | A request holds one statement, after any SET, so an answer has one result. |
driver.RowsColumnTypeScanType |
yes | The header names the SQL type and the native type of each column (D164). |
driver.RowsColumnTypeDatabaseTypeName |
yes | The header names the SQL type of each column, such as BIGINT, or OTHER for a complex type. |
driver.RowsColumnTypeLength |
no | The header names no length. |
driver.RowsColumnTypeNullable |
yes | The header does not say whether a column can be NULL, and every type can be. |
driver.RowsColumnTypePrecisionScale |
no | The header names no precision and no scale, and a DECIMAL is a double. |
These are faults of the Go drivers that a driver here does not repeat:
- kaplanmaxe reads the whole body with
ioutil.ReadAll, returnsdriver.ErrSkipfromPrepareandBegin, ignores the arguments, and creates anhttp.Transportfor each query (kaplanmaxe,connection.go). - godruid gives an array and a JSON value as the JSON text in a
[]byte, and aTIMESTAMPas astring(godruid,druidsql/rows.go). - godruid answers
ExecwithRowsAffected(0), and sends a write to the SQL API, which refuses it (godruid,druidsql/conn.go). - Neither Go driver notices an error after some rows, because neither one
checks for the closing
]or the empty line (godruid and kaplanmaxe).
Step 7 tested each lead on the server. Gemini was asked on 2026-10-02. DeepSeek timed out on each of the four times that it was asked the questions of step 7, on 2026-10-02.
- Named parameters. Gemini said that Druid has only
?. The server agrees (recorded: "a named parameter"). - Transactions. Gemini said that there are none, and that a task replaces
segments as one change. The server refuses
BEGIN. - Paging. Gemini said that the statements API pages, but that without durable storage the result is one page of at most 3,000 rows. The server gave one page, and refused durable storage.
- Types. Gemini said that a
DATEcan be text with no time. The server sends the text of its midnight. Gemini said thatMV_TO_ARRAYwithsqlStringifyArraysfalse gives a typed array. The server agrees. - Cancel. Gemini said that the server does not stop a query when the client leaves. The server agrees.
UPSERT. Gemini said that it is a word of the grammar of Calcite, and that Druid refuses it. The server agrees.- The end of a result. Gemini said that
arrayandobjectend with], and that the lines formats have no sign of their end. The server ends each lines format with an empty line, and leaves it out after an error.
Step 8a asked both models on 2026-10-02 to review the mapping of the types against TYPES.md and D135. DeepSeek timed out on each of three tries. Gemini answered:
- It agreed with the integers, the floats with their strings, the boolean, the strings, the timestamp, the date, the array, the JSON value and the sketch as bytes.
- It said that a
DECIMALis afloat64, because its native type isDOUBLEand it holds no exact digits. The table follows D135, which gives a decimal an*apd.Decimal. See the open questions. - It said that a multi-value string stays a
string, because the server names itVARCHAR. - It said that the SQL type
NULLneeds a row of the kind null, for aSELECT NULL. The table has one. - It said that a boolean stored in a datasource is a
BIGINT, because the server names it so. The server does (recorded: "every type").
The coordinator asked DeepSeek again on 2026-10-02, through
deepseek-v4-pro, because deepseek-flash timed out once more. It
answered:
- It agreed with the integers, the floats with their strings, the boolean, the timestamp, the date, the array, the JSON value, the sketch as bytes and NULL.
- It said that a
DECIMALis afloat64, because the server computes it as a double, and an*apd.Decimalwould claim a precision that the server does not keep. Gemini said the same. See the open questions. - It said that a multi-value string with more than one value is a
[]any, decoded from the JSON text, and a single value stays astring. Gemini said the opposite. See the open questions.
Ken settled the first four questions of step 8 on 2026-10-02, and D163 and D164 hold the answers:
- The driver reads only. It sends
INSERTandREPLACEto the SQL API, which refuses them, and never to the task API (D163). - The driver reads
arrayLines, and an answer that ends with no empty line is an error that wrapsdbimp.ErrIncomplete(D164). - A
DECIMALis afloat64(D164). - A multi-value
VARCHARwith more than one value is a[]anyof its strings, and one value stays astring(D164).
Two facts wait for dbmeta, and not for the driver (D164): dbmeta_user
cannot read the version, and the nano quickstart has two task slots, so the
tests run one task at a time.
Ken answered the questions of the steps after step 9 on 2026-10-07, and D164 holds the answers:
- The driver reads a plain string whose text is a JSON array of two or more
strings, such as
["x","y"], as a[]anytoo, because the server names it as it names a multi-value string (druid/types.go). - The driver cancels a query by its id when the context of the query ends before the driver reads the whole answer, and when the caller closes the rows before their end, as Neo4j does (D105).
- The port is 8888 when the DSN names none, with
tls=truetoo, as Pinot keeps 8099 (D129). dbrungives theurlof each principal ashttp://, and the tests read it as the DSNdruid://(druid/integration_test.go). Ken asked thedbmetasession to give the six new entries the scheme of their driver, as D156 did for Avatica. Until it does, the conversion stays in the tests.
Step 17a compares this driver with couchbase, the first driver (D97). It
was written on 2026-10-02 from the staged code. A fact of Couchbase comes
from COUCHBASE.md, and a fact of Druid from the sections
above.
| Couchbase | Apache Druid | |
|---|---|---|
| Request | POST /query/service, with statement, args and $name |
POST /druid/v2/sql on the Router, with query, resultFormat, the three keys of the header, context and parameters (Requests) |
| Database | The key query_context of the body |
None. Every datasource is in the schema druid (The DSN) |
| Language | SQL++, which is close to SQL | SQL, parsed by Calcite. A write is SQL of the multi-stage engine, which only the task API runs (Requests) |
| DDL | In SQL++ | None. A write makes a datasource, and the Coordinator drops it. Every statement of DDL is a syntax error (Statements) |
| Parameters | ?, $1 and $name |
? only, each a typed {type, value} (Parameters) |
| Framing | One body for the whole result, which does not page | One body for the whole result, which the server writes as the query runs. arrayLines writes one row on each line, and an empty line at the end (Responses) |
| Columns | signature, before the first row |
The three rows of the header: the names, the native types and the SQL types (Responses) |
| Order | The projection on 7.6 and 8.0, the names on 7.2 | The statement |
| Errors | Can come with HTTP 200, after some rows | Before any row, a status that is not 2xx with a JSON object. After some rows, HTTP 200 with no empty line at the end and no other sign (Errors) |
| Types | JSON. No date, decimal, UUID or binary | JSON, with a SQL type and a native type for each column. A time is ISO 8601 text, an array is JSON text, a JSON value is its text, and a sketch is base64 (Types) |
| Cancel | The server stops a query when the client leaves | The query runs on when the client leaves, and DELETE /druid/v2/sql/<sqlQueryId> stops it, which the driver sends when the context ends or the rows close early (Cancellation and timeouts) |
| Transactions | BEGIN WORK in SQL++, carried by txid |
None. BEGIN is a syntax error (Transactions) |
| Authentication | Basic, or creds in the body |
Basic |
| Default port | 8093, or 18093 with TLS | 8888 on the Router |
The differences that a caller sees:
- Every write fails with the refusal of the server, because the SQL API takes none (D163).
- A query has no database, and
WithDatabasefails (D164). - The server binds each argument as a typed parameter that the driver names from the Go type of the argument, and a named argument is refused (D164).
- A result that the server cut short after some rows fails with
dbimp.ErrIncompleteanddruid.ErrCut, because the answer ends with no empty line (D164). - The server does not stop a query when the client leaves, so the driver cancels it by its id when its context ends (D164).
couchbase |
druid |
|
|---|---|---|
| Size, without tests, on 2026-10-02 | About 1300 lines in 8 files | About 1500 lines in 8 files |
Config |
QueryContext, ScanConsistency, Timeout, Durability, TxTimeout |
Host, Port, TLS, User, Password, TimeZone, Timeout |
| Options for one statement | Six With options for one statement, through WithOptions or an argument, and two for BeginTx, through WithOptions only (D40, D46 and D109). WithDatabase sets query_context, as WithQueryContext does. WithParameter sets any key of the body |
WithTimeout, WithReadonly, WithParameter, WithDatabase and WithTimeZone, through WithOptions or an argument (D109). WithTimeout sends timeout in the context. WithReadonly changes nothing, because every statement is read-only. WithDatabase gives dbimp.ErrNotSupported. WithParameter("context") replaces the whole context, the id of the query too |
| Arguments | Sent to the server as args and $name |
Sent as parameters, each with the SQL type of its Go type: BIGINT, DOUBLE, BOOLEAN, VARCHAR, TIMESTAMP in milliseconds, DATE, DECIMAL and ARRAY. A named argument, a []byte, NaN and an infinity are refused (druid/types.go) |
| Rows | dbimp.ObjectRows from the root package, after the driver reads the signature. SELECT RAW has a reader of its own |
A reader of its own for arrayLines, which keeps the last two bytes of the body to see the empty line (druid/rows.go) |
| Types of the columns | ColumnTypeDatabaseTypeName and ColumnTypeScanType from the signature, and ColumnTypeNullable |
ColumnTypeDatabaseTypeName, the SQL type, and ColumnTypeScanType, from the SQL type or the native type of OTHER. Every column can be NULL |
| Values | int64, float64, or *apd.Decimal for an integer too large for int64. Bytes are decoded from base64 (D44) |
By the type of the column: int64, float64 for a DECIMAL too, bool, string, []any for a multi-value string with more than one value, time.Time, dbimp.Date, []any for an array, the decoded value of JSON, and []byte for a sketch (D164) |
Result of Exec |
RowsAffected from metrics.mutationCount |
RowsAffected and LastInsertId return dbimp.ErrNotSupported, because the SQL API changes no rows (D163) |
| Transactions | BeginTx sends BEGIN WORK. ReadOnly sends readonly |
BeginTx returns dbimp.ErrNotSupported (D164) |
| Reset of a session | ResetSession, which it keeps as a guard (D41 and D102), and IsValid |
None. A connection holds nothing on the server |
| Cancel | The request carries the context, and net/http stops it when the context ends (D36 and D42) |
Each query has its own sqlQueryId. When the context ends before the driver read the whole answer, the driver sends DELETE /druid/v2/sql/<sqlQueryId> with a limit of 5 seconds (D164) |
| Errors | *ResponseError, with the HTTP status, the status of the body, and a list of Error{Code, Msg} |
*Error{HTTPStatus, Code, Category, Persona, Class, Message}, which unwraps to *dbimp.StatusError, and ErrCut for an answer with no empty line |
| Authentication | Basic | Basic, and the driver follows no redirect, so the credentials go to the host of the DSN only (D164) |
| Other exports | The With options and Option |
The With options and Option, Error and ErrCut |
The differences that a caller sees:
- A value keeps its type, a time and a sketch too, where Couchbase gives JSON shapes (D135 and D164).
- A
DECIMALis afloat64, where D135 gives a decimal an*apd.Decimal, because the server computes it as a double (D164). - A multi-value string with more than one value is a
[]any, so one column can give astringin one row and a[]anyin the next (D164). WithReadonlysucceeds and does nothing, where Couchbase sendsreadonly, because the SQL API takes no write (D163).RowsAffectedalways returns an error, where Couchbase counts every statement bymutationCount(D163).- A result that the server cut short fails with
ErrCutanddbimp.ErrIncompleteafter its rows (D164).