Skip to content

Commit 3dba407

Browse files
SNOW-1025489 enforce ms scale for timestamps (#2758)
1 parent 36a6568 commit 3dba407

8 files changed

Lines changed: 241 additions & 38 deletions

File tree

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1414
- Log a warning when using http protocol for OAuth urls.
1515
- Deprecated support for custom revocation error classes in OCSP response cache deserialization. By default, only `RevocationCheckError` exceptions are deserialized from OCSP cache. Custom exception classes can be temporarily enabled by setting the `SNOWFLAKE_ENABLE_CUSTOM_REVOCATION_ERRORS` environment variable to `true` or `1`, but this support will be removed in a future release.
1616
- Bumped up vendored `urllib3` to `2.6.3`
17+
- Added `force_microseconds_precision` to `cursor.fetch_arrow_all` and `cursor.fetch_pandas_all` to avoid PyArrow schema incosistency between batches.
1718

1819
- v4.2.0(January 07,2026)
1920
- Added `SnowflakeCursor.stats` property to expose granular DML statistics (rows inserted, deleted, updated, and duplicates) for operations like CTAS where `rowcount` is insufficient.

src/snowflake/connector/cursor.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,21 @@ def query_result(self, qid: str) -> SnowflakeCursor:
13371337
)
13381338
return self
13391339

1340-
def fetch_arrow_batches(self) -> Iterator[Table]:
1340+
def fetch_arrow_batches(
1341+
self,
1342+
force_microsecond_precision: bool = False,
1343+
) -> Iterator[Table]:
1344+
"""Fetch Arrow Tables in batches.
1345+
1346+
Args:
1347+
force_microsecond_precision: When True, all timestamp columns are converted
1348+
to microsecond precision, ensuring consistent schema across all batches.
1349+
This is useful when your data contains timestamps outside the nanosecond
1350+
range (1677-2262), such as '9999-12-31' or '0001-01-01'. When False
1351+
(default), precision is determined per-batch based on the data, which
1352+
may cause pyarrow schema mismatch errors when combining batches.
1353+
Note: enabling this truncates sub-microsecond precision (scale 7-9).
1354+
"""
13411355
self.check_can_use_arrow_resultset()
13421356
if self._prefetch_hook is not None:
13431357
self._prefetch_hook()
@@ -1346,20 +1360,43 @@ def fetch_arrow_batches(self) -> Iterator[Table]:
13461360
self._log_telemetry_job_data(
13471361
TelemetryField.ARROW_FETCH_BATCHES, TelemetryData.TRUE
13481362
)
1349-
return self._result_set._fetch_arrow_batches()
1363+
return self._result_set._fetch_arrow_batches(
1364+
force_microsecond_precision=force_microsecond_precision
1365+
)
13501366

13511367
@overload
1352-
def fetch_arrow_all(self, force_return_table: Literal[False]) -> Table | None: ...
1368+
def fetch_arrow_all(
1369+
self,
1370+
force_return_table: Literal[False] = ...,
1371+
force_microsecond_precision: bool = ...,
1372+
) -> Table | None: ...
13531373

13541374
@overload
1355-
def fetch_arrow_all(self, force_return_table: Literal[True]) -> Table: ...
1375+
def fetch_arrow_all(
1376+
self,
1377+
force_return_table: Literal[True],
1378+
force_microsecond_precision: bool = ...,
1379+
) -> Table: ...
1380+
1381+
def fetch_arrow_all(
1382+
self,
1383+
force_return_table: bool = False,
1384+
force_microsecond_precision: bool = False,
1385+
) -> Table | None:
1386+
"""Fetch all results as a single Arrow Table.
13561387
1357-
def fetch_arrow_all(self, force_return_table: bool = False) -> Table | None:
1358-
"""
13591388
Args:
13601389
force_return_table: Set to True so that when the query returns zero rows,
1361-
an empty pyarrow table will be returned with schema using the highest bit length for each column.
1362-
Default value is False in which case None is returned in case of zero rows.
1390+
an empty pyarrow table will be returned with schema using the highest
1391+
bit length for each column. Default value is False in which case None
1392+
is returned in case of zero rows.
1393+
force_microsecond_precision: When True, all timestamp columns are converted
1394+
to microsecond precision, ensuring consistent schema across all batches.
1395+
This is useful when your data contains timestamps outside the nanosecond
1396+
range (1677-2262), such as '9999-12-31' or '0001-01-01'. When False
1397+
(default), precision is determined per-batch based on the data, which
1398+
may cause pyarrow schema mismatch errors when combining batches.
1399+
Note: enabling this truncates sub-microsecond precision (scale 7-9).
13631400
"""
13641401
self.check_can_use_arrow_resultset()
13651402

@@ -1368,7 +1405,10 @@ def fetch_arrow_all(self, force_return_table: bool = False) -> Table | None:
13681405
if self._query_result_format != "arrow":
13691406
raise NotSupportedError
13701407
self._log_telemetry_job_data(TelemetryField.ARROW_FETCH_ALL, TelemetryData.TRUE)
1371-
return self._result_set._fetch_arrow_all(force_return_table=force_return_table)
1408+
return self._result_set._fetch_arrow_all(
1409+
force_return_table=force_return_table,
1410+
force_microsecond_precision=force_microsecond_precision,
1411+
)
13721412

13731413
def fetch_pandas_batches(self, **kwargs: Any) -> Iterator[DataFrame]:
13741414
"""Fetches a single Arrow Table."""

src/snowflake/connector/nanoarrow_cpp/ArrowIterator/CArrowTableIterator.cpp

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -335,10 +335,12 @@ void CArrowTableIterator::reconstructRecordBatches_nanoarrow() {
335335

336336
CArrowTableIterator::CArrowTableIterator(PyObject* context, char* arrow_bytes,
337337
int64_t arrow_bytes_size,
338-
const bool number_to_decimal)
338+
const bool number_to_decimal,
339+
const bool force_microsecond_precision)
339340
: CArrowIterator(arrow_bytes, arrow_bytes_size),
340341
m_context(context),
341-
m_convert_number_to_decimal(number_to_decimal) {
342+
m_convert_number_to_decimal(number_to_decimal),
343+
m_force_microsecond_precision(force_microsecond_precision) {
342344
if (py::checkPyError()) {
343345
return;
344346
}
@@ -745,8 +747,11 @@ void CArrowTableIterator::convertTimestampColumn_nanoarrow(
745747
// Find epoch and fraction arrays for overflow detection
746748
ArrowArrayView* epochArray = nullptr;
747749
ArrowArrayView* fractionArray = nullptr;
748-
bool has_overflow_to_downscale = false;
749-
if (scale > 6 && field->type == NANOARROW_TYPE_STRUCT) {
750+
// When m_force_microsecond_precision is true, always use microsecond
751+
// precision to ensure consistent schema across all batches
752+
bool has_overflow_to_downscale = m_force_microsecond_precision;
753+
if (!m_force_microsecond_precision && scale > 6 &&
754+
field->type == NANOARROW_TYPE_STRUCT) {
750755
for (int64_t i = 0; i < field->schema->n_children; i++) {
751756
ArrowSchema* c_schema = field->schema->children[i];
752757
if (std::strcmp(c_schema->name, internal::FIELD_NAME_EPOCH.c_str()) ==
@@ -977,9 +982,10 @@ void CArrowTableIterator::convertTimestampTZColumn_nanoarrow(
977982
}
978983
}
979984

980-
// Check for timestamp overflow and determine if downscaling is needed
981-
bool has_overflow_to_downscale = false;
982-
if (scale > 6 && byteLength == 16) {
985+
// When m_force_microsecond_precision is true, always use microsecond
986+
// precision to ensure consistent schema across all batches
987+
bool has_overflow_to_downscale = m_force_microsecond_precision;
988+
if (!m_force_microsecond_precision && scale > 6 && byteLength == 16) {
983989
has_overflow_to_downscale = _checkNanosecondTimestampOverflowAndDownscale(
984990
columnArray, epochArray, fractionArray);
985991
}

src/snowflake/connector/nanoarrow_cpp/ArrowIterator/CArrowTableIterator.hpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ class CArrowTableIterator : public CArrowIterator {
2323
* Constructor
2424
*/
2525
CArrowTableIterator(PyObject* context, char* arrow_bytes,
26-
int64_t arrow_bytes_size, bool number_to_decimal);
26+
int64_t arrow_bytes_size, bool number_to_decimal,
27+
bool force_microsecond_precision = false);
2728

2829
/**
2930
* Destructor
@@ -49,6 +50,8 @@ class CArrowTableIterator : public CArrowIterator {
4950
/** local time zone */
5051
char* m_timezone;
5152
const bool m_convert_number_to_decimal;
53+
/** force microsecond precision for timestamps to ensure consistent schema */
54+
const bool m_force_microsecond_precision;
5255

5356
/**
5457
* Reconstruct record batches with type conversion in place

src/snowflake/connector/nanoarrow_cpp/ArrowIterator/nanoarrow_arrow_iterator.pyx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ cdef extern from "CArrowTableIterator.hpp" namespace "sf":
6464
char* arrow_bytes,
6565
int64_t arrow_bytes_size,
6666
bint number_to_decimal,
67+
bint force_microsecond_precision,
6768
) except +
6869

6970

@@ -100,6 +101,7 @@ cdef class PyArrowIterator(EmptyPyArrowIterator):
100101
cdef object check_error_on_every_column
101102
cdef object number_to_decimal
102103
cdef object pyarrow_table
104+
cdef bint force_microsecond_precision
103105

104106
def __cinit__(
105107
self,
@@ -109,7 +111,8 @@ cdef class PyArrowIterator(EmptyPyArrowIterator):
109111
object use_dict_result,
110112
object numpy,
111113
object number_to_decimal,
112-
object check_error_on_every_column
114+
object check_error_on_every_column,
115+
object force_microsecond_precision=False,
113116
):
114117
self.context = arrow_context
115118
self.cIterator = NULL
@@ -122,6 +125,7 @@ cdef class PyArrowIterator(EmptyPyArrowIterator):
122125
self.table_returned = False
123126
self.arrow_bytes = <char*>arrow_bytes
124127
self.arrow_bytes_size = len(arrow_bytes)
128+
self.force_microsecond_precision = force_microsecond_precision
125129

126130
def __dealloc__(self):
127131
del self.cIterator
@@ -202,9 +206,10 @@ cdef class PyArrowTableIterator(PyArrowIterator):
202206
object use_dict_result,
203207
object numpy,
204208
object number_to_decimal,
205-
object check_error_on_every_column
209+
object check_error_on_every_column,
210+
object force_microsecond_precision=False,
206211
):
207-
super().__init__(cursor, py_inputstream, arrow_context, use_dict_result, numpy, number_to_decimal, check_error_on_every_column)
212+
super().__init__(cursor, py_inputstream, arrow_context, use_dict_result, numpy, number_to_decimal, check_error_on_every_column, force_microsecond_precision)
208213
if not INSTALLED_PYARROW:
209214
raise Error.errorhandler_make_exception(
210215
ProgrammingError,
@@ -225,6 +230,7 @@ cdef class PyArrowTableIterator(PyArrowIterator):
225230
self.arrow_bytes,
226231
self.arrow_bytes_size,
227232
self.number_to_decimal,
233+
self.force_microsecond_precision,
228234
)
229235
cdef ReturnVal cret = self.cIterator.checkInitializationStatus()
230236
if cret.exception:

src/snowflake/connector/result_batch.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def _create_nanoarrow_iterator(
6161
number_to_decimal: bool,
6262
row_unit: IterUnit,
6363
check_error_on_every_column: bool = True,
64+
force_microsecond_precision: bool = False,
6465
):
6566
from .nanoarrow_arrow_iterator import PyArrowRowIterator, PyArrowTableIterator
6667

@@ -84,6 +85,7 @@ def _create_nanoarrow_iterator(
8485
numpy,
8586
number_to_decimal,
8687
check_error_on_every_column,
88+
force_microsecond_precision,
8789
)
8890
)
8991

@@ -669,7 +671,10 @@ def __repr__(self) -> str:
669671
return f"ArrowResultChunk({self.id})"
670672

671673
def _load(
672-
self, response: Response, row_unit: IterUnit
674+
self,
675+
response: Response,
676+
row_unit: IterUnit,
677+
force_microsecond_precision: bool = False,
673678
) -> Iterator[dict | Exception] | Iterator[tuple | Exception]:
674679
"""Creates a ``PyArrowIterator`` from a response.
675680
@@ -683,13 +688,15 @@ def _load(
683688
self._numpy,
684689
self._number_to_decimal,
685690
row_unit,
691+
force_microsecond_precision=force_microsecond_precision,
686692
)
687693

688694
def _from_data(
689695
self,
690696
data: str | bytes,
691697
iter_unit: IterUnit,
692698
check_error_on_every_column: bool = True,
699+
force_microsecond_precision: bool = False,
693700
) -> Iterator[dict | Exception] | Iterator[tuple | Exception]:
694701
"""Creates a ``PyArrowIterator`` files from a str.
695702
@@ -710,6 +717,7 @@ def _from_data(
710717
self._number_to_decimal,
711718
iter_unit,
712719
check_error_on_every_column,
720+
force_microsecond_precision=force_microsecond_precision,
713721
)
714722

715723
@classmethod
@@ -741,7 +749,10 @@ def from_data(
741749
return new_chunk
742750

743751
def _create_iter(
744-
self, iter_unit: IterUnit, connection: SnowflakeConnection | None = None
752+
self,
753+
iter_unit: IterUnit,
754+
connection: SnowflakeConnection | None = None,
755+
force_microsecond_precision: bool = False,
745756
) -> Iterator[dict | Exception] | Iterator[tuple | Exception] | Iterator[Table]:
746757
"""Create an iterator for the ResultBatch. Used by get_arrow_iter."""
747758
if self._local:
@@ -754,6 +765,7 @@ def _create_iter(
754765
if connection
755766
else None
756767
),
768+
force_microsecond_precision=force_microsecond_precision,
757769
)
758770
except Exception:
759771
if connection and getattr(connection, "_debug_arrow_chunk", False):
@@ -763,7 +775,11 @@ def _create_iter(
763775
logger.debug(f"started loading result batch id: {self.id}")
764776
with TimerContextManager() as load_metric:
765777
try:
766-
loaded_data = self._load(response, iter_unit)
778+
loaded_data = self._load(
779+
response,
780+
iter_unit,
781+
force_microsecond_precision=force_microsecond_precision,
782+
)
767783
except Exception:
768784
if connection and getattr(connection, "_debug_arrow_chunk", False):
769785
logger.debug(f"arrow data can not be parsed: {response}")
@@ -773,10 +789,16 @@ def _create_iter(
773789
return loaded_data
774790

775791
def _get_arrow_iter(
776-
self, connection: SnowflakeConnection | None = None
792+
self,
793+
connection: SnowflakeConnection | None = None,
794+
force_microsecond_precision: bool = False,
777795
) -> Iterator[Table]:
778796
"""Returns an iterator for this batch which yields a pyarrow Table"""
779-
return self._create_iter(iter_unit=IterUnit.TABLE_UNIT, connection=connection)
797+
return self._create_iter(
798+
iter_unit=IterUnit.TABLE_UNIT,
799+
connection=connection,
800+
force_microsecond_precision=force_microsecond_precision,
801+
)
780802

781803
def _create_empty_table(self) -> Table:
782804
"""Returns empty Arrow table based on schema"""
@@ -789,27 +811,50 @@ def _create_empty_table(self) -> Table:
789811
]
790812
return pa.schema(fields).empty_table()
791813

792-
def to_arrow(self, connection: SnowflakeConnection | None = None) -> Table:
814+
def to_arrow(
815+
self,
816+
connection: SnowflakeConnection | None = None,
817+
force_microsecond_precision: bool = False,
818+
) -> Table:
793819
"""Returns this batch as a pyarrow Table"""
794-
val = next(self._get_arrow_iter(connection=connection), None)
820+
val = next(
821+
self._get_arrow_iter(
822+
connection=connection,
823+
force_microsecond_precision=force_microsecond_precision,
824+
),
825+
None,
826+
)
795827
if val is not None:
796828
return val
797829
return self._create_empty_table()
798830

799831
def to_pandas(
800-
self, connection: SnowflakeConnection | None = None, **kwargs
832+
self,
833+
connection: SnowflakeConnection | None = None,
834+
force_microsecond_precision: bool = False,
835+
**kwargs,
801836
) -> DataFrame:
802837
"""Returns this batch as a pandas DataFrame"""
803838
self._check_can_use_pandas()
804-
table = self.to_arrow(connection=connection)
839+
table = self.to_arrow(
840+
connection=connection,
841+
force_microsecond_precision=force_microsecond_precision,
842+
)
805843
return table.to_pandas(**kwargs)
806844

807845
def _get_pandas_iter(
808-
self, connection: SnowflakeConnection | None = None, **kwargs
846+
self,
847+
connection: SnowflakeConnection | None = None,
848+
force_microsecond_precision: bool = False,
849+
**kwargs,
809850
) -> Iterator[DataFrame]:
810851
"""An iterator for this batch which yields a pandas DataFrame"""
811852
iterator_data = []
812-
dataframe = self.to_pandas(connection=connection, **kwargs)
853+
dataframe = self.to_pandas(
854+
connection=connection,
855+
force_microsecond_precision=force_microsecond_precision,
856+
**kwargs,
857+
)
813858
if not dataframe.empty:
814859
iterator_data.append(dataframe)
815860
return iter(iterator_data)
@@ -824,12 +869,22 @@ def create_iter(
824869
):
825870
"""The interface used by ResultSet to create an iterator for this ResultBatch."""
826871
iter_unit: IterUnit = kwargs.pop("iter_unit", IterUnit.ROW_UNIT)
872+
force_microsecond_precision: bool = kwargs.pop(
873+
"force_microsecond_precision", False
874+
)
827875
if iter_unit == IterUnit.TABLE_UNIT:
828876
structure = kwargs.pop("structure", "pandas")
829877
if structure == "pandas":
830-
return self._get_pandas_iter(connection=connection, **kwargs)
878+
return self._get_pandas_iter(
879+
connection=connection,
880+
force_microsecond_precision=force_microsecond_precision,
881+
**kwargs,
882+
)
831883
else:
832-
return self._get_arrow_iter(connection=connection)
884+
return self._get_arrow_iter(
885+
connection=connection,
886+
force_microsecond_precision=force_microsecond_precision,
887+
)
833888
else:
834889
return self._create_iter(iter_unit=iter_unit, connection=connection)
835890

0 commit comments

Comments
 (0)