Skip to content

bulkcopy() silently truncates fractional seconds in string datetime values to microseconds #869

Open

Description

@oscarrobertson

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

  1. 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.
  2. Alternatively, send string values for datetime columns to the server as text and let it convert, matching the execute path.
  3. 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

  1. github-actions commented on Oct 27, 2026

    @github-actions

    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!

  2. added
    bugSomething isn't working
    triage doneIssues that are triaged by dev team and are in investigation.
    and removed
    triage neededFor new issues, not triaged yet.
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingtriage doneIssues that are triaged by dev team and are in investigation.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions