Repository navigation
bulkcopy() silently truncates fractional seconds in string datetime values to microseconds #869
Description
Summary
When a string is bound to a DATETIME2 column through Cursor.bulkcopy(), the Rust core parses it with Python's datetime.fromisoformat before sending it. datetime only holds microseconds, so a seven-digit fraction is truncated to six with no warning. The same string through execute()/executemany() is sent to the server as text and SQL Server stores all seven digits.
On Python 3.10 the same parser also rejects a trailing Z, which the server accepts. From 3.11 that case works. This is probably fine since python 3.10 is EOL in a few days, but I wanted to raise since your docs explicitly say you need >=3.10 for bulkcopy.
To reproduce
import uuid
import mssql_python
conn = mssql_python.connect(CONN_STR, autocommit=True)
cur = conn.cursor()
table = f"dbo.Repro_{uuid.uuid4().hex[:8]}"
cur.execute(f"CREATE TABLE {table} (lbl VARCHAR(20), dt DATETIME2)")
value = "2024-07-01 12:00:00.1234567"
cur.executemany(f"INSERT INTO {table} VALUES (?, ?)", [("executemany", value)])
cur.bulkcopy(table, [("bulkcopy", value)])
cur.execute(f"SELECT lbl, CONVERT(varchar(27), dt, 121) FROM {table}")
print(cur.fetchall())
cur.execute(f"DROP TABLE {table}")Observed:
Python 3.11:
[('executemany', '2024-07-01 12:00:00.1234567'),
('bulkcopy', '2024-07-01 12:00:00.1234560')]
Python 3.10, where fromisoformat also rejects Z:
cur.bulkcopy(table, [("bulkcopy", "2024-07-01T12:00:00Z")])
ValueError: Invalid datetime format '2024-07-01T12:00:00Z'. Expected ISO format like
'2024-01-15 09:30:00' or '2024-01-15T09:30:00': ValueError: Invalid isoformat string: ...
Note:
bulkcopy_arrow() with a timestamp[ns] column stores all seven digits, into both DATETIME2 and DATETIMEOFFSET:
import pyarrow as pa
ns = pa.array([1719835200_123456700], pa.timestamp("ns")) # 2024-07-01 12:00:00.1234567
cur.bulkcopy_arrow(table, pa.table({"lbl": ["arrow"], "dt": ns}))
# -> '2024-07-01 12:00:00.1234567'
Expected behavior
bulkcopy() stores the same value as executemany() for the same input string. In particular, a DATETIME2(7) column receives all seven digits, and inputs that SQL Server accepts are not rejected on the client.
Suggested fixes, in order of preference
- Parse ISO 8601 strings in Rust directly into the TDS DATETIME2/DATETIMEOFFSET representation, which has 100 ns resolution, the same way the Arrow path already consumes nanosecond buffers. This also removes the dependence on the running interpreter's fromisoformat behaviour.
- Alternatively, send string values for datetime columns to the server as text and let it convert, matching the execute path.
- At minimum, raise when a fraction has more digits than can be represented instead of truncating. Silent data loss is the worst of the three outcomes.
Activity
github-actions commentedon Oct 27, 2026
Hi oscarrobertson, thank you for opening this issue!
Our team will review it shortly. We aim to triage all new issues within 24-48 hours and get back to you.
If you have additional information to share, please feel free to update the issue.
Thank you for your patience!