Repository navigation
Keep DateTime64 precision in pandas.Timestamp binds and inserts and in Tuple, Nested, Variant and Map binds - #1118
Merged
joe-clickhouse merged 7 commits intoOct 29, 2026
Conversation
sebastianbreguel
requested review from
joe-clickhouse and
peter-leonov-ch
as code owners
October 26, 2026 23:25
# Conflicts: # CHANGELOG.md
2 tasks done
Promote Variant values only when the sole datetime-bearing alternative is scalar DateTime64, preserving existing behavior for ambiguous hints. Reuse and cache parse_columns for Tuple and Nested element hints, including tab and newline separators. Share nanosecond rendering with SQLAlchemy and keep DT64Param subclasses compatible when they omit the base initializer. Add regression coverage, scope the pandas row-insert test to the Python codec, and update driver and SQLAlchemy documentation and the changelog.
joe-clickhouse
force-pushed
the
fix/pd-timestamp-nanoseconds
branch
from
October 29, 2026 22:05
acd8bd3 to
04a6de2
Compare
joe-clickhouse
approved these changes
Oct 29, 2026
joe-clickhouse
left a comment
Contributor
There was a problem hiding this comment.
Approved.
On top of your work, I:
- Restricted Variant promotion to a sole scalar DateTime64 datetime alternative, preserving existing ambiguous behavior and avoiding timezone regressions.
- Reused and cached parse_columns for Tuple/Nested hints, fixing whitespace handling without duplicating parsing or adding per-row overhead.
- Shared nanosecond rendering with SQLAlchemy and fixed compatibility with DT64Param subclasses that skip the base initializer.
- Added regression tests, scoped Python-codec insert coverage correctly, and updated the docs and changelog.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The substring check misclassifies enum labels and tuple field names as datetime alternatives, causing fractional-second loss during binding and requiring parsed-type classification with regression coverage.
1 open issue
What changed in this PR
Preserves DateTime64 precision across query binding and Python-codec row inserts, including pandas nanoseconds and datetime values inside containers.
Changes:
- Retains pandas nanoseconds in driver and SQLAlchemy binds, and fixes timestamp rounding during Python-codec inserts.
- Extends container binding support with per-element timezones and cached type parsing.
- Adds regression coverage and documents supported behavior and limitations.
| File | Description |
|---|---|
| tests/unit_tests/test_driver/test_params.py | Tests nanoseconds, container promotion, timezones, and parsing caches. |
| tests/integration_tests/test_sqlalchemy/test_datetime64.py | Tests pandas nanoseconds in SQLAlchemy parameters and literals. |
| tests/integration_tests/test_params.py | Tests container and Variant binding through both clients. |
| tests/integration_tests/test_pandas.py | Tests nanosecond binds and Python-codec row inserts. |
| docs/sqlalchemy.mdx | Documents nanosecond preservation in typed binds. |
| docs/driver-api.mdx | Documents container support and Variant restrictions. |
| clickhouse_connect/driver/binding.py | Preserves nanoseconds and promotes container elements with timezone hints. |
| clickhouse_connect/datatypes/temporal.py | Preserves timestamp nanoseconds and corrects second-boundary rounding. |
| clickhouse_connect/cc_sqlalchemy/datatypes/base.py | Reuses nanosecond rendering and honors wrapper timezones. |
| CHANGELOG.md | Records precision fixes and container-binding behavior. |
Review effort: Balanced
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
This PR fixes two ways
DateTime64values lose sub-second precision on the way to the server. The second part was opened separately as #1130 and is folded in here, since both touchbinding.pyandtest_params.py.1.
pandas.Timestampnanoseconds in binds and row insertspandas.Timestampvalues lost their nanoseconds on two paths that handleDateTime64(9).insert_dfalready keeps the nanoseconds, and so does apd.Timedeltabound toTime64(9).DT64Param.formatusedstrftime("%f"), which only has microseconds. It now appendsTimestamp.nanosecondwhen it is non-zero. Plaindatetimeoutput is unchanged.DateTime64._datetime64_ticksusedfloor(value.timestamp())plusmicrosecond. For values that are not plaindatetime, it now takes whole seconds from the timestamp and adds the exact sub-second part (microsecond * 1000 + nanosecond).This also fixes a second problem on the same path.
Timestamp.timestamp()rounds to microseconds, so03:04:05.999999999became...06.0. The floor then landed on the next second, and the row was stored one second late in anyDateTime64column. The same happened for pre-epoch values. The new code subtracts the sub-second part before rounding, so this cannot happen.The plain
datetimepath is untouched, because it is the hot loop for row inserts. The only added cost is atype(value) is not datetimecheck per value.Related to #429. That issue asks for nanosecond-precise binding of custom types. This PR does not add custom formatters, but it covers the built-in case behind it: binding and inserting nanosecond
pandas.Timestampvalues.Out of scope, left unchanged: the Rust codec's
DateTime64insert, which has the same microsecond floor; non-64DateTimeinserts; and client-side%sbinding of a baredatetime.2.
DateTime64elements inside namedTuple,Nested,VariantandMapbindsWhen a
datetimeis bound server-side to aDateTime64element,_promote_datetime64wraps it inDT64Paramto keep sub-second precision. This only worked at the top level, inArray, and in unnamedTuple. Other container types sent whole seconds (ClickHouse 26.3,D = datetime(2024, 1, 2, 3, 4, 5, 123456),U= the same time in UTC):Named elements failed because
"a DateTime64(6)"was read as a type calleda.What changed, all in
clickhouse_connect/driver/binding.py:_strip_element_nameremoves the name from a named element before promotion. It handles quoted names, including backtick or double quotes with doubled or backslash-escaped quotes, and names containing parentheses.DateTime64 (6)is not mistaken for a name._promote_datetime64now also handlesNested(as an array of tuples),Variant, andMap.Mapis handled only withdict_parameter_format='map'. JSON mode serializes the dict itself and is unchanged.Variantis promoted only when it has no plainDateTimealternative. I checked on the server that a value with a fraction makes it pickDateTime64instead ofDateTime. Promoting would therefore change the variant thatVariant(DateTime, DateTime64(6))gets today, so that case stays as it is.DT64Paramtakes an optionaltz. A promoted element carries the timezone named in its ownDateTime64type, soTuple(a DateTime64(6, 'Asia/Tokyo'), b DateTime64(6, 'UTC'))renders each element in its own zone. Before, the whole parameter used one timezone. Top-level binds andArraybinds produce the same strings as before. ADT64Paramwith no timezone placed inside one of these containers gets the element's timezone too. The wrapper is copied, so the caller's object is not changed. The SQLAlchemy subclass_DateTime64QueryParamhonors that timezone. ADT64Paramthat already has a timezone, and the_64suffix, are unchanged._unwrap_outerand the element-timezone lookup are cached (lru_cache(maxsize=256)). Before this, every array element parsed the same type string again. Both caches hold only immutable results.Still unchanged: plain
DateTime(non-64) elements in a multi-timezoneTuplestill share the first timezone found. That case has no wrapper, and fixing it is a separate change.Verification
Both parts together, merged with
main(f77899c):ruff checkandmypyare clean. Unittest_params.pyandtest_parser.py: 301 passed. Integrationtest_params.pyandtest_pandas.pyagainst ClickHouse 26.3: 104 passed.Part 1
Unit test
test_dt64param_keeps_pandas_timestamp_nanoseconds. Onmainit fails:'2024-01-02 03:04:05.123456' == '2024-01-02 03:04:05.123456789'.Integration tests (sync and async):
test_pandas_timestamp_datetime64_bind_nanosecondsandtest_pandas_timestamp_datetime64_insert_nanoseconds. The insert test coversDateTime64(9),DateTime64(6),Nullable(DateTime64(9, 'UTC'))with a tz-aware value, a.999999999carry case, and a 1960 value. Onmainall 4 fail, for example.123456000vs.123456789, and04:06.999999vs04:05.999999for the carry. With the fix all 4 pass.I compared the new tick formula with the old one on 200,000 random plain
datetimevalues from 1900 to 2300: 0 mismatches.ruff format --check,ruff check, andmypyare clean. Unit tests: 4338 passed. Integration (-n 4, ClickHouse 26.3): 1796 passed, 29 skipped. 2 failures aretest_wrong_port_error_message, which needs local port 9000 (environment).Performance: I serialized 3 x 300k plain
datetimevalues withDateTime64._write_column_binary. There were 20 interleaved runs per side, each in a fresh process, measured with/usr/bin/time -l. The machine was shared, with load average around 18.The differences are within noise.
/usr/bin/timeCPU resolution is 10 ms.Part 2
New unit tests in
TestBindQueryDateTime64Precision: named tuple, named tuple tz hint, per-element timezones, quoted names (4 variants), existingDT64Param/ SQLAlchemy wrappers taking the element timezone (directly and throughVariant), nested, variant, map. Plus a guard showing thatVariant(DateTime, DateTime64(6))is unchanged.Integration tests
test_datetime64_params_in_containers(6 cases, including a wrappedDT64Param) andtest_datetime64_params_in_map, sync and async. They compare against the server'stoString. Onmain, 25 of the selected unit and integration cases fail. With the fix all pass.ruff format --check,ruff check, andmypyare clean. Unit tests: 4352 passed. Integration (-n 4, ClickHouse 26.3, including the SQLAlchemy suite): 1806 passed, 29 skipped. 2 failures aretest_wrong_port_error_message, which needs local port 9000 (environment). 18 unit errors aretest_httpclient.pytests that hardcodelocalhost:8123(environment).Performance:
bind_queryonorigin/mainvs this PR, timed with/usr/bin/time -l. There were 16 interleaved runs per side, each in a fresh process, and the order alternated each round. Python 3.12, Apple Silicon. Wall and CPU cover the bind loop. I started each case once the 1-minute load average was under 3; it stayed between 2.5 and 3.3 during the runs. Cases:plain:{x:Int32}, {s:String}, {d:DateTime}, 20,000 binds.arr1k:Array(DateTime64(6))with 1,000 values, 200 binds.arr100k: 100,000 values, 2 binds._tz: the same type with'Asia/Tokyo'.Nothing got slower. DateTime64 array binds are about 18 to 43% faster because of the parse cache, and
plainabout 11%. Peak RSS is within noise: the medians differ by at most 0.1 MiB.Checklist