ClickHouse: what runs differently¶
ClickHouse lacks parts of SQL hare would otherwise rely on: unique and foreign key constraints, an
UPDATE in place, RETURNING, ON CONFLICT, row locks, a count of the rows a write changed.
Transactions and generated keys exist only on a server with a ClickHouse Keeper. Each row below says what
hare does: where ClickHouse has another form of the same SQL, hare writes it; where the language has none,
the call is refused before any SQL is sent.
NullIf() needs a result type that ClickHouse can wrap in Nullable. It raises UnSupportedError
before SQL for arrays, tuples, maps, nested values, Dynamic, Variant and other types that cannot be
wrapped this way. Dynamic and Variant can contain null values themselves, but ClickHouse's NULLIF
still requires the unsupported Nullable wrapper.
| ClickHouse lacks | hare |
|---|---|
| Transactions, by default | atomic() raises UnSupportedError unless the connection asks for them with transactions=true — see Transactions and locks. Without, hare's own multi-statement writes run one statement after another, a failure leaving the statements before it written. |
| Savepoints | A nested atomic() joins the transaction it is nested in; an error leaving it rolls the whole transaction back. |
| Generated keys | With a ClickHouse Keeper, the keys are taken from a series before the rows are written (generateSerialID) — see Models. Without one, a model whose key the database generates is refused when it is bound. |
| Unique and foreign key constraints | A uniqueness or a relation with db_constraint=True is refused — a model when it is bound (ConfigurationError), a migration declaring a uniqueness before its first statement (UnSupportedError); see Models. |
A CHECK passing a row its condition gives NULL |
ClickHouse refuses such a row (IntegrityError): CheckConstraint(check=Q(price__gte=0)) refuses price=None — a check meant to take NULL names it, Q(price__isnull=True) \| Q(price__gte=0). A check a migration adds checks only the rows written later — see Operations. |
An UPDATE of rows in place |
save(), QuerySet.update() and bulk_update() run an ALTER TABLE ... UPDATE mutation, finished before the call returns — see An UPDATE of rows in place |
| A count of the rows an UPDATE or DELETE changed | update(), delete() and bulk_update() return None (Features.reports_written_row_count) — see A count of written rows |
RETURNING |
returning() of an update() or a delete() and bulk_create(returning=True) raise UnSupportedError; the values the server gives a written row stay unread on the instance — see RETURNING |
ON CONFLICT |
bulk_create() with ignore_conflicts, update_fields, on_conflict or on_conflict_constraint raises UnSupportedError (Features.supports_insert_conflicts); the transactional outbox, which writes its events so, is refused when its model is bound — see Outbox. |
| Batches of an UPDATE | BackfillColumn fills every row with one mutation — batch_size isn't used: a mutation locks no row a batch would spare, and each one rewrites the parts it changes. |
| Row locks | select_for_update() and atomic(lock_timeout=...) raise UnSupportedError — see Transactions and locks. |
NULL from an aggregate over no rows |
An aggregate whose result over no rows would be a value no row holds is written by its -OrNull form (maxOrNull(...)) and gives None; Sum gives ClickHouse's own 0 (None for a nullable field) — see NULL from an aggregate over no rows for the list |
LIKE ... ESCAPE |
A pattern escapes with a backslash, ClickHouse's own escape character. An empty text is contained in every text, as on PostgreSQL (contains="", contains=F("name") of an empty name): ClickHouse matches no empty string by '%%', so hare writes that pattern as '%'. |
A plain UPDATE, and a refused NULL, in raw SQL |
SQL you write (RunSQL, raw(), execute()) runs as ClickHouse takes it: an UPDATE statement needs the table's enable_block_number_column setting (ALTER TABLE ... UPDATE doesn't), and a NULL inserted into a column that isn't Nullable is written as the type's empty value (0, ''). hare's own writes refuse such a None. |
| A backslash as a plain character of a quoted name | A name holding a backslash — a table, a column, an alias — is written with it doubled: ClickHouse reads a single one as an escape. |
A recursive query adding rows by UNION DISTINCT |
with_recursive() needs max_depth, which ends a cycle in the rows — see Recursive queries and CTEs. Without it the walk raises UnSupportedError. |
NULL first in a descending ordering |
ClickHouse puts NULL last in both directions — see NULL first in a descending ordering |
A key with a NULL part in __in |
Q(relation__in=[...]) of a relation to a composite key compares a tuple of columns: for a row whose key holds NULL ClickHouse gives false where SQLite and PostgreSQL give NULL — filter() takes the same rows, an annotated condition (annotate(x=Q(...))) holds False, not None |
| A cursor of a transaction | stream() reads rows outside a transaction too: the server sends them as it computes them. Leaving the async for early closes the stream and gives the connection back. |
| A zone's UTC offset with seconds | Read without its seconds — see A zone's UTC offset with seconds. |
A zone's UTC offset with seconds¶
ClickHouse keeps a zone's offset in whole minutes: a local mean time before a zone's standard time
(Moscow before 1919, +02:30:17) is read without its seconds — Extract("second"), Trunc() to a day
or longer and __time of such a moment in that zone (or with use_timezone=False in it) differ from
PostgreSQL's by those seconds; Trunc() to an hour, a minute or a second takes the offset whole.
An UPDATE of rows in place¶
save(), QuerySet.update() and bulk_update() run an ALTER TABLE ... UPDATE mutation, finished
before the call returns. A table of ClickhouseTableOptions(lightweight_updates=True) takes a
lightweight UPDATE instead — see Models. A
delete() runs a lightweight DELETE, and an ALTER TABLE ... DELETE mutation when its condition reads
a union()/intersection()/difference() — the server's lightweight DELETE fails on one. A mutation
that fails is stopped (KILL MUTATION) before the error is raised: the server would retry it, fail every
later mutation of the table behind it, and could still apply it after the call raised. A mutation runs no
correlated subquery: an update() setting a field to a Subquery of OuterReference, or filtered or
deleted by one, raises UnSupportedError. From ClickHouse 26.7 the server refuses a mutation setting a
field without NULL to an expression that may be NULL — a Subquery of a nullable field
(CANNOT_INSERT_NULL_IN_ORDINARY_COLUMN) — even when its value isn't; wrap such an expression in
Coalesce() with a value. A bulk_update() mutation writes at most 1000 rows
(Features.max_bulk_update_rows): it finds each row's values by scanning the list of rows, so a longer
one costs more per row and outgrows the server's AST limit. Its rows' keys are read by a subquery of the
list itself, the same on every replica: it runs with allow_nondeterministic_mutations = 1, which a
ReplicatedMergeTree table needs for a mutation reading a subquery — an update() filtered by a
subquery of another table is refused there by the server, as replicas might read different rows.
Mutations of one table running at once are the server's own: each reads the rows as they were when it
started, so two concurrent update(n=F("n") + 1) of one row may both write n + 1 — one increment
lost, where PostgreSQL's row lock would serialize them. Run such updates of a row one after another.
A count of written rows¶
ClickHouse reports no count of the rows a mutation or a lightweight UPDATE/DELETE changed, so
update(), delete() and bulk_update() return None; read the rows to learn what changed. A delete hare
runs itself — a cascade in Python, a model overriding delete() — counts the rows it read for it. Without
a count:
- a
save()ordelete()of a row another writer removed goes unnoticed — noDoesNotExistis raised; - a model with
Meta.optimistic_lock_fieldis refused when it is bound: a stale version would go unnoticed; - a
bulk_update()counts every object as written, and applies its version, its snapshot of changed fields and itsauto_nowvalues to all of them.
A reverse relation's add()/set() reads the rows first instead — a row the related model's default
scope hides (another tenant's, a soft-deleted one) is refused with IntegrityError before the write, as
on the other databases.
RETURNING¶
update().returning(), delete().returning() and bulk_create(returning=True) raise
UnSupportedError before any SQL is sent. A value the server gives a row a save() or create() writes
— a db_default, a MATERIALIZED column — stays unread on the instance: the field holds
DatabaseDefault, a computed field isn't loaded, and refresh_from_db() reads them. A key the server
generates is taken before the insert (see Models); a model whose key is left to a
db_default is refused when a row is written.
NULL from an aggregate over no rows¶
Max, Min, Avg, StdDev, Variance and ClickHouse's own ArgMax, ArgMin, AnyValue,
AnyLast, Quantile and Median (exact=True too) are written by their -OrNull form
(maxOrNull(...)), so over no rows they give None rather than the column type's default; Count gives
0 and Sum gives ClickHouse's own 0 (0.00 for a decimal; None for a nullable field, and over
only NULL values too). In a query with GROUP BY a group holds
at least one row, so an aggregate without _filter keeps its plain, cheaper form (max(...)). A sample
deviation over one value is None, as in SQL — ClickHouse gives NaN.
Results that are ClickHouse's own¶
hare doesn't make ClickHouse give PostgreSQL's result. It checks what it guarantees itself — a value it
writes, an enum's member — writes the forms it stores itself (an enum by its label, an address as an
IPv6, a JSON path) and supplies its functions by ClickHouse's own means; everything else is
ClickHouse's own, and differs from PostgreSQL's:
- Division and remainder by zero give
infornanfor floats and ClickHouse's own error for integers; a math function outside its domain (Sqrt(-1),Ln(0)) givesnanorinf. hare checks none of them.MODand a negative zero are ClickHouse's own. - Decimal arithmetic is ClickHouse's own. A quotient has the dividend's places, cut, not rounded
(
F("price") / 8of12.34is1.54). A sum or a product past its type's digits wraps around (aDecimal128past 38 digits), a decimal beside an integer keeps the decimal's own type (Decimal(9, 4)plus a largeInt64wraps the integer's digits), and a product past the result type's digits raises ClickHouse'sDecimal math overflow.Avg(),StdDev()andVariance()of decimals are floats (Features.computes_decimal_statistics_exactly);Sum()of decimals is aDecimal(38, places), a quantile keeps the decimal.Round()is ClickHouse'sround(). A value the server computed wider than 38 digits (aSum()of aDecimal128past them) is read with every digit by clickhouse-driver; clickhouse-connect rounds it to 38 digits as it reads it — the library's own. - Counts, lengths, row numbers and ranks (
Count(),Length(),StrIndex(), and the window functionsRowNumber(),Rank(),DenseRank(),NTile()) are signedInt64values: ClickHouse's ownUInt64finds no common type with a signed integer inCoalesce(),Case()orGreatest(). - Integer arithmetic is ClickHouse's own: a result past its type's range wraps around
(
9223372036854775807 + 1is-9223372036854775808), a valueupdate(n=F("n") + 1)writes too (2147483647 + 1stored as-2147483648in anInt32), and so doSum()andAvg()of integers, summed in the column's type (Avg()of twoUInt64values2**64 - 1is2**63);Abs()of a type's minimum is the minimum./of two integers gives the exact quotient, a fraction too (Features.divides_integers_exactly) — the expression's output field is aFloatField. A decimal an expression computes with more places than the field keeps is cut by ClickHouse, not rounded:update(price=F("price") + Decimal("0.005"))leaves12.34where PostgreSQL and SQLite write12.35. Cast()is ClickHouse's own conversion. To an integer it cuts the fraction off, where PostgreSQL rounds, and wraps a number past the target's range around (Cast(Value(3e9), IntField())is-2147483648); to a decimal it cuts the places past the target's, a float converted as the double it is (2.675becomes2.67). Text is read by ClickHouse's parsers: text that isn't a number isNULL, a number padded with spaces (' 42 ') too; a date or a moment is read byparseDateTime64BestEffort()— a date missing a part is completed ("2024-01"is the first of January), a clock past its day rolls over ("25:00"is one o'clock of the next day), a year outside 1900 to 2299 is the nearest day ClickHouse holds — from ClickHouse 26.7, whoseDateTime64holds the years 1 to 9999, such a year is read as written, and a date of it past 2299 is aDate32day clickhouse-driver can't read (its ownKeyError) — a day no calendar has ("2023-02-29") raises. A cast to a boolean is ClickHouse'stoBool(), which acceptsyes,no,onandofftoo and raises on any other text. A cast to text istoString():1e308,-inf, a decimal without trailing zeros, a moment as2024-01-02 03:04:05.678900. ACharField'smax_lengthcuts nothing.- A moment shifted by a duration is ClickHouse's own
addMicroseconds(): a moment past the years a column stores is the nearest one it holds —DateTime1900-01-01 to 2299-12-31 23:00 (DateTime64from ClickHouse 26.7: the years 1 to 9999),Date32up to 2299-12-31. union(),intersection()anddifference()of ordered branches keep no duplicate: the connection runs with ClickHouse'soptimize_distinct_in_orderoff, which kept duplicates of such branches — also inside a subquery, whose ownSETTINGSa set operation doesn't read.Concat()joins the text ClickHouse gives a column's value (toString()); a naive datetime (use_timezone=False) is written at the machine's local zone. A Python value given as a literal is its Python text: a booleantrueorfalse, a float byrepr()(1000000000000000.0), a date or datetime as ISO text. A text lookup (contains,startswith,iexact, ...) on a boolean, float or decimal column matches the text ClickHouse gives the value.- Case mapping.
Upper(),Lower()and thei...lookups go throughupperUTF8()andlowerUTF8(), the server's own — thei...lookups compare upper-cased text, soiexact="ß"matchesSSandsstoo (upperUTF8('ß')isSS). Substr,ChrandOrdaresubstringUTF8()(a start of 0 is refused, a negative start counts from the end, a negative length leaves characters off the end),char()(a code point no character has is refused, 0 gives\x00) and the code point of the first character forOrd. The text functions have no 32-bit limit on a length, a position or a count, and take an expression of any type; a literal of the wrong type is refused.FIRST_VALUEandLAST_VALUEare ClickHouse's own, as is the order of a UUID (lower 64 bits first — auuid7key doesn't sort by time, so order by a date column) and of anIPv6address. An enum orders by its label, which hare stores that way. A time of day orders and compares as its stored text:10:00:00+03:00sorts after08:00:00+00:00, though it is the earlier moment.Lag/Leadwith a JSONdefaultof an array holding values of different types ([1, 2.5, None, "x"]) fail over the HTTP driver withNO_COMMON_TYPE, though aCASTof the same JSON passes — the server's own window functions do so (checked on ClickHouse 25.8.33.6).- A NaN in a
FloatFieldis stored. A null byte in a text raisesValidationError— the value is written into SQL text, which can't hold one; inside a JSON object it is stored as ClickHouse stores it. - Errors. Making a column
NOT NULLwhile it holdsNULLraisescolumn X of table Y holds NULL.
NULL first in a descending ordering¶
ClickHouse puts NULL last in both directions; hare writes a descending ordering as DESC NULLS FIRST,
so NULL sorts last going up and first going down, and pages after a cursor, reverse() and last()
keep every row. A window's ValueRange with an offset orders by the value as a
Float64, a NULL as an infinity on the side NULL sorts to: ClickHouse's own range around a NULL
takes other rows into it. ArrayAgg(order_by=...) puts a NULL ordering value where the ordering does.
Long __in lists¶
The server parses a list of literals slowly: 100,000 of them take most of a second. A __in or
__not_in list of at least 500 values of one plain type — UUIDs, integers, texts, dates, or rows of them
for a key of several columns — is bound as one parameter: a read sends the values beside its statement as
an external table (x IN hare_set_1), which the server reads as data in a few milliseconds; any other
statement (a mutation, a DELETE), and a read in a transaction, which reads no external table, writes
them as a list of literals. The SQL of the query is the same for a list of any length, so it keeps one
plan.
Indexes are data skipping indexes: Index(fields=...) creates a minmax index of granularity 1, and
hare.dialects.clickhouse.indexes has the other types — see Models.
hare inspectdb and hare drift read the tables, columns, primary keys, comments, skipping indexes,
table options, views and dictionaries from the system tables.
Correlated subqueries¶
ClickHouse runs correlated subqueries, but computes some of them wrongly or refuses them:
- A correlated subquery inside
IN/NOT INis refused by ClickHouse 25.8, 25.10, 25.12, 26.3, 26.7, 26.8 and 26.9 withUnSupportedErrorfrom the server, even where a correlated scalar subquery runs. EXISTScorrelated by equal columns — of a composite key's row of them too — is written as(columns) IN (SELECT ...)— the server refuses its own correlatedEXISTSin a join'sONand over aUNION DISTINCT(25.8). AnEXISTSof aunion()is written as theEXISTSof either part, each so. One correlated by more than equalities in itsWHERE(version__gt=OuterReference("version"), asLatestVersionsis) runs as the server's ownEXISTS; in anUPDATEorDELETE, or correlated outside itsWHERE, it raisesUnSupportedError. A shape the server doesn't run yet raisesUnSupportedErrorwith its message.- A grouping by a correlated subquery (
values("author_name").annotate(...)of aSubquerywithOuterReference) raisesUnSupportedError— the server groups by none. - A subquery ordering or slicing its own rows (
Subquery(... .order_by("-created")[:1])withOuterReference) raisesUnSupportedError— the server refuses it. - A subquery selecting
Count,Sumor another aggregate of its rows, correlated by a column rows of the query around it share (Book.objects.annotate(n=Subquery(Award.objects.filter(author=OuterReference( "author"))...))) raisesUnSupportedError(Features.supports_correlated_aggregates): the server reads each of its rows once per outer row holding the same value, and counts them that many times. Correlated by the outer primary key or a unique field (OuterReference("pk")) it runs, as doMax,MinandAvgby any column. Count over the relation withannotate(Count("books"))instead. - A subquery in
ORDER BY, or selected beside aWHEREis computed in a derived table: the rows are selected with noWHERE, the conditions and the sort keys as hidden columns of it, and the query around it filters, orders and slices them. The SQL is longer; the result is the one the query asks for. - An
UPDATEorDELETEwhose condition holds a correlated subquery raisesUnSupportedError— a mutation runs none. - An
OuterReferenceacross a relation (OuterReference("author__name")) reads a column of a table joined to the query around the subquery, which the server correlates by none: it raisesUnSupportedError. Reach the relation inside the subquery and correlate by a column of the outer table itself (Author.objects.filter(books__id=OuterReference("id"))), or read the related field without a subquery (F("author__name")). - A table a subquery joins is read through a derived table, as the subquery's own one: an aliased table answers to its own name too on ClickHouse, and the subquery's would take the outer query's name.
Faults of a server version¶
hare doesn't work around a fault of a ClickHouse version; the server's own error or result stands:
- A column made
Nullable(AlterFieldtonull=True): ClickHouse before 26.3 reads the NULL map the parts stored before the change lack as every value NULL —filter(title__isnull=True)matches every such row. On these versions rewrite the column after the migration yourself:ALTER TABLE "article" UPDATE "title" = "title" WHERE 1(in aRunSQLafter theAlterField). Until then the checks of a later migration of the column read the same: rolling the change back to NOT NULL is refused withIntegrityError(holds NULL), and a value a type change can't convert in such a part isn't found before the change. - An ordering by a JSON path (
order_by("data__n")): ClickHouse 25.8 withallow_simdjson=0, which hare sets to read an integer past 64 bits, may order some rows of aJSONcolumn out of place, depending on the data. Order by a cast of the path (Cast("data__n", IntField())) there. - An
update()setting a constant value under a negation that folds into a constant (exclude(owner__isnull=True).update(balance=0)of a non-nullableowner): ClickHouse before 26.3 refuses it with Cannot convert column ... because it is non constant in source stream. - ClickHouse 25.12 refuses a query that joins a many-to-many relation for an aggregate next to a
select_related()object (Player.objects.annotate(Count("skills")).select_related("team")) with Cannot fold actions for projection (LOGICAL_ERROR) — its join reordering; 25.10 and 26.x run it.
Text order¶
ClickHouse orders and compares text by its UTF-8 bytes, as SQLite does by default; PostgreSQL by the
collation of its database, whose order of a locale puts a next to A and letters with accents next to
their base letters. order_by(), __gt/__lt and Min/Max of a CharField, a TextField or a
CharEnumField (compared by its label) follow the database's order; Collate(...) asks a database for
an order of its own — on ClickHouse in order_by() alone (order_by(Collate("name", "en")), written
ORDER BY name ASC COLLATE 'en'), since the server takes a collation nowhere else.
JSON¶
A JSONField is a JSON column, its paths stored as typed subcolumns (the outbox's payload and
headers excepted — see Outbox); over clickhouse-driver, which
reads no JSON column, it is a String column of JSON text, read by the JSON* functions. Which one a
connection has follows from its driver, before it opens — hare drift, hare sqlmigrate and the migrations
compare, print and create the column of that connection. A table made over one driver is read, written and
filtered over the other as well, but for a key holding a dot: write it and filter by it over the
driver that made the table — over the other, clickhouse-connect writes it into a String column with the dot
escaped (%2E), and clickhouse-driver matches no such key of a JSON column; reading it is right over both;
hare drift over the other reports the column's AlterField — the two
keep a null differently, below. The JSON type stores less than JSON text does, and a value it would
read back otherwise raises ValidationError before anything is sent — a String column, over
clickhouse-driver, keeps them all:
- Only an object is stored: a list, a string or a number at the top level is refused.
- A key holding
Noneor an empty object is refused, at any depth ({"a": None},{"items": [{"b": {}}]}) — the type keeps no such path. ANoneor an empty object inside an array ([None, 1],[{}]) is kept. Sofield__path=None, aNoneinfield__path__in=[...]andfield__path__isnull=Truematch a path with no value — a row whose whole value is NULL has none —,field__path__not=Noneand aNoneinfield__path__not_in=[...]leave it out, and a comparison of the path (field__path__lt=10) doesn't match it. - An integer outside the range of an
Int64and aUInt64(-2**63to2**64 - 1) is refused — the type holds it as a float. - clickhouse-connect types a path of the rows one
bulk_create()sends by the first value it meets: an integer after a float at the same path of the batch is stored and read back as a float, and a filter on its exact value finds nothing. Keep a path's numbers of one type in a batch, or write such rows one by one; clickhouse-driver keeps the JSON text as it is. - The order of keys isn't kept.
- A dot in a key stays part of the key: each connection sets
json_type_escape_dots_in_keys, and hare writes the dot as the server escapes it,%2E— anupdate()'s mutation, which reads the JSON with the server's own settings, too. A key holding the text%2Eitself raisesValidationError: the server would read it back as a dot. - An array of integers and floats holds them all as floats:
[1, 2.5]reads back as[1.0, 2.5]; one whose integers (its nested arrays' included) go past2**53, which a float doesn't hold exactly, is refused, and so is one holding a boolean beside a number or a string — a mutation would read[true, 1]as[1, 1]and["", true]as["", "true"]. A boolean stays one, and a string stays a string even when it looks like a date or a moment ("2024-01-02"): each connection setsinput_format_try_infer_datesandinput_format_try_infer_datetimesoff. Butupdate(),save()andbulk_update()write by a mutation, which ClickHouse runs with the server's own settings, whatever the connection sets: a date-like string is stored as aDateor aDateTimepath and read back as adateor adatetimeunless the server's default profile sets those two settings to0.
Key paths, __has_key, __has_keys and __has_any_keys work on both; __filter, __contains and
__contained_by don't — ClickHouse's JSON has no containment, and they raise UnSupportedError; filter
by key paths instead. __has_key, __has_keys and __has_any_keys find a key of an object only
(JSONHas()). A migration moves a String column to JSON with MODIFY COLUMN.
A JSONField compared whole (=, __not, __in, __not_in) on a JSON column has the value cast to
JSON and compared by ClickHouse, so the key order doesn't matter; __in compares each value so, joined
by OR. A JSON column holds an object only: a compared value that is no object (a list, a string, a
number) raises ValidationError. On a String column of JSON text — over clickhouse-driver — the JSON
text is compared as it was written.
An ordering of a path (__gt, __gte, __lt, __lte, __range, order_by()) uses the Dynamic value
ClickHouse reads of the JSON text (JSONExtract(..., 'Dynamic'), with the session setting
allow_suspicious_types_in_order_by=1). ClickHouse orders a Dynamic by the name of its type first and
by the value second: a Float64 2.5 comes before an Int64 9, a boolean before the numbers and the
strings after them. Comparing a path that holds both strings and numbers raises ClickHouse's
NO_COMMON_TYPE. jsonb's order of objects, arrays, booleans, numbers, strings and null is
PostgreSQL's alone, and a comparison with an object or an array is ClickHouse's own.
Cast(..., TextField()) and Concat of a JSONField give the text ClickHouse writes: the column's key
order (the JSON type's own, the written one in a String column) and its spelling of a number
(1e+20, 12.0). A text lookup of a path matches the text ClickHouse reads of the value.
JSONObject() and JSONArray() are written by toJSONString(): a decimal without trailing zeros, a
DateTime64 as 2024-02-29 13:45:06.789000, a NaN as null, bytes as the hex the column holds.
A BinaryField is a String column holding the bytes' lowercase hex: clickhouse-connect reads a
String as text and writes bytes that aren't UTF-8 as their hex, so raw bytes couldn't be read back. The
hex reads back exactly over both drivers and orders as the bytes do; unhex() gives the bytes in SQL.
Bulk loads¶
bulk_create() sends its rows in one binary insert — ClickHouse's Native format, a column after a
column — instead of an INSERT ... VALUES text: no literal is written for a value, and the server parses
no SQL. That is how rows are meant to reach ClickHouse, and the reason to write many rows by
bulk_create() rather than by create() in a loop.
batch_sizesplits the objects into as many inserts; without it they go in one.- The values are the ones
create()writes: a moment is the instant it stands for (a naive one underuse_timezone=Falsethe instant of its local wall clock, read back as that wall clock), a JSON value is its text or its object. - The insert is reported to observers, spans and the query counter as
INSERT INTO <table> (<columns>) FORMAT Native— the rows are in no SQL text. use_copy=Trueasks for the same load and changes nothing here.returning=Trueand the conflict options raiseUnSupportedError.- With
async_insert=truethe server collects the rows of many inserts before it writes them; withwait_for_async_insert=falsethe call returns before they are written and reports no error.