You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Writing a Java null into a non-nullableArray(...) column via RowBinaryFormatWriter serializes two bytes (00 00) where the correct RowBinary encoding of an empty array is a single byte (00). The server reads the extra 00 as a phantom extra row; in a multi-column row it misframes every following column and the insert fails with CANNOT_READ_ALL_DATA. This is silent data corruption, not an error.
This is distinct from #2931/#2932 (null → non-nullable Enum8/16, which was an NPE and is now fixed to throw). Here the defect is a double-write in the Array (and Dynamic) branch of writeValuePreamble.
} elseif (value == null) { // non-nullable column, Java nullif (dataType == ClickHouseDataType.Array) {
SerializerUtils.writeNonNull(out); // emits 0x00 <-- extra byte
} elseif (dataType == ClickHouseDataType.Dynamic) {
// do nothing
} else {
thrownewIllegalArgumentException(
String.format("An attempt to write null into not nullable column '%s'", column));
}
}
writeValuePreamble returns true, so the caller then calls SerializerUtils.serializeData → serializeArrayData, which for a null value writes the var-uint length 0 (another 0x00):
Net wire output for null into a non-nullable Array: 00 00. Every other non-nullable type reaches the else and throws; Array/Dynamic are special-cased to let null through.
Empirical evidence (ClickHouse 26.5.1.882, raw RowBinary insert)
Single-column table CREATE TABLE t (a Array(Int32)) ENGINE=Memory:
raw bytes inserted
count()
SELECT a
00 (correct empty-array encoding)
1
[[]]
00 00 (what the client emits for null)
2
[[],[]] ← phantom row
Multi-column table CREATE TABLE t (a Array(Int32), b Int32), intending one row a=[] , b=7; the extra 00 shifts the frame:
Non-nullable Nested(...) + null → throwsIllegalArgumentException: An attempt to write null into not nullable column (the safe behavior).
Scope to address (all four sites)
non-defaults Array — the corrupting double-write above.
non-defaults Dynamic — preamble does nothing, then serializeData(null, Dynamic) runs valueToColumnForDynamicType(null); needs verification/handling.
RowBinaryWithDefaults (defaultsSupport) Array/Dynamic — same special-case, but here the leading writeNonNull is the "no-default" marker, so the current output is a valid00 00 = "value present, empty array" (i.e. it currently coerces null→[] without corruption). Only fires when the column has no DDL default (a column with a default already correctly emits the "use-default" marker).
Fix direction — needs a maintainer decision
Both candidate behaviors are a change from today's silent corruption:
Option 2 — treat null as an empty array (drop the extra writeNonNull in the non-defaults Array branch so only serializeArrayData's single var-uint 0 is written). Preserves the apparent original intent — the RowBinaryWithDefaults branch already coerces null→[] non-corruptingly, and arrays have a natural "empty" representation. Smaller, more permissive.
Recommendation: Option 1 (throw).#2931/#2932 posed the identical fork for Enum ("clear IllegalArgumentException" vs. "Python-style permissive / treat as default") and the merged fix (#2932, 2026-07-16) chose to throw. Applying the same choice to Array/Dynamic keeps null-into-non-nullable uniform across all types, and users retain explicit escape hatches (declare the column Nullable(...), pass [], or use RowBinaryWithDefaults with a DDL default). Option 2 is defensible but would make Array/Dynamic the only non-nullable types that silently accept null.
Whichever is chosen, it should be applied consistently to the Dynamic branch and the RowBinaryWithDefaults path (for the no-default case) so all four sites agree.
Origin
Found during work on PR #2936 (issue #2477, Nested RowBinary writer) as a separate, pre-existing defect on main. Filing so the fix direction can be decided before a PR is opened.
Target
ClickHouse/clickhouse-java— moduleclient-v2(RowBinaryFormatWriter/RowBinaryFormatSerializer).Summary
Writing a Java
nullinto a non-nullableArray(...)column viaRowBinaryFormatWriterserializes two bytes (00 00) where the correct RowBinary encoding of an empty array is a single byte (00). The server reads the extra00as a phantom extra row; in a multi-column row it misframes every following column and the insert fails withCANNOT_READ_ALL_DATA. This is silent data corruption, not an error.This is distinct from #2931/#2932 (null → non-nullable
Enum8/16, which was an NPE and is now fixed to throw). Here the defect is a double-write in theArray(andDynamic) branch ofwriteValuePreamble.Root cause
client-v2/.../data_formats/RowBinaryFormatSerializer.java,writeValuePreamble— non-defaults branch:writeValuePreamblereturnstrue, so the caller then callsSerializerUtils.serializeData→serializeArrayData, which for anullvalue writes the var-uint length0(another0x00):Net wire output for
nullinto a non-nullableArray:00 00. Every other non-nullable type reaches theelseand throws;Array/Dynamicare special-cased to letnullthrough.Empirical evidence (ClickHouse 26.5.1.882, raw RowBinary insert)
Single-column table
CREATE TABLE t (a Array(Int32)) ENGINE=Memory:count()SELECT a00(correct empty-array encoding)[[]]00 00(what the client emits fornull)[[],[]]← phantom rowMulti-column table
CREATE TABLE t (a Array(Int32), b Int32), intending one rowa=[] , b=7; the extra00shifts the frame:Contrast (current, correct behaviors — must stay unchanged)
Array+ emptyList/array[]→ single00(correct).Nullable(Array(...))+null→01null-marker (correct).Nested(...)+null→ throwsIllegalArgumentException: An attempt to write null into not nullable column(the safe behavior).Scope to address (all four sites)
Array— the corrupting double-write above.Dynamic— preamble does nothing, thenserializeData(null, Dynamic)runsvalueToColumnForDynamicType(null); needs verification/handling.RowBinaryWithDefaults(defaultsSupport)Array/Dynamic— same special-case, but here the leadingwriteNonNullis the "no-default" marker, so the current output is a valid00 00= "value present, empty array" (i.e. it currently coercesnull→[]without corruption). Only fires when the column has no DDL default (a column with a default already correctly emits the "use-default" marker).Fix direction — needs a maintainer decision
Both candidate behaviors are a change from today's silent corruption:
IllegalArgumentException(same message/behavior as every other non-nullable type, includingNested, and the just-merged Enum fix fix(client-v2): clear error for null in non-nullable Enum column #2932). Loudest, most consistent.nullas an empty array (drop the extrawriteNonNullin the non-defaultsArraybranch so onlyserializeArrayData's single var-uint0is written). Preserves the apparent original intent — theRowBinaryWithDefaultsbranch already coercesnull→[]non-corruptingly, and arrays have a natural "empty" representation. Smaller, more permissive.Recommendation: Option 1 (throw). #2931/#2932 posed the identical fork for
Enum("clearIllegalArgumentException" vs. "Python-style permissive / treat as default") and the merged fix (#2932, 2026-07-16) chose to throw. Applying the same choice toArray/Dynamickeeps null-into-non-nullable uniform across all types, and users retain explicit escape hatches (declare the columnNullable(...), pass[], or useRowBinaryWithDefaultswith a DDL default). Option 2 is defensible but would makeArray/Dynamicthe only non-nullable types that silently acceptnull.Whichever is chosen, it should be applied consistently to the
Dynamicbranch and theRowBinaryWithDefaultspath (for the no-default case) so all four sites agree.Origin
Found during work on PR #2936 (issue #2477,
NestedRowBinary writer) as a separate, pre-existing defect onmain. Filing so the fix direction can be decided before a PR is opened.