Skip to content
32 changes: 29 additions & 3 deletions google/cloud/bigquery/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ class SchemaField(object):

Only valid for top-level schema fields (not nested fields).
If the type is FOREIGN, this field is required.

timestamp_precision: Optional[int]
Precision (maximum number of total digits in base 10) for seconds
of TIMESTAMP type.

Possible values include:

- None (Default, for TIMESTAMP type with microsecond precision)

- 12 (For TIMESTAMP type with picosecond precision)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should there be an enum or something for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an option, I did not choose it because the backend defined it to be an integer, and I think we can let the backend handle value validation. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other repos, this is something I'd define an enum for, and then accept either the enum or a raw int value

But I don't have too much context on this repo, so up to you if that makes sense here. Consistency with the rest of the code is probably more relevant

"""

def __init__(
Expand All @@ -213,6 +223,7 @@ def __init__(
range_element_type: Union[FieldElementType, str, None] = None,
rounding_mode: Union[enums.RoundingMode, str, None] = None,
foreign_type_definition: Optional[str] = None,
timestamp_precision: Optional[int] = None,
):
self._properties: Dict[str, Any] = {
"name": name,
Expand All @@ -237,6 +248,8 @@ def __init__(
if isinstance(policy_tags, PolicyTagList)
else None
)
if timestamp_precision is not None:
self._properties["timestampPrecision"] = timestamp_precision
if isinstance(range_element_type, str):
self._properties["rangeElementType"] = {"type": range_element_type}
if isinstance(range_element_type, FieldElementType):
Expand Down Expand Up @@ -374,6 +387,19 @@ def policy_tags(self):
resource = self._properties.get("policyTags")
return PolicyTagList.from_api_repr(resource) if resource is not None else None

@property
def timestamp_precision(self):
"""Precision (maximum number of total digits in base 10) for seconds of
TIMESTAMP type.

Possible values include:

- None (Default, for TIMESTAMP type with microsecond precision)

- 12 (For TIMESTAMP type with picosecond precision)
Copy link
Contributor

@daniel-sanche daniel-sanche Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No mention of None?

(It says 6 is the default. Is the server enforcing that, or the client? Can this even return None?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server does return None, if we do not set the value. This docstring is copied from the proto files. I just tried to set value to 6, and received the following error:
Invalid value for timestampPrecision: 6 is not a valid value

So here we might need to give up consistency with the proto and make sure the doc is user friendly. WDYT? I will also open a bug with the API team.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opened internal bug 463739109 and updated docstring.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually in protos, None is equivalent to 0. So I'd recommend removing the Optional here, and have it return 0 if unset.

But yeah, we should make sure we understand the expected values here first

"""
return _helpers._int_or_none(self._properties.get("timestampPrecision"))

def to_api_repr(self) -> dict:
"""Return a dictionary representing this schema field.

Expand Down Expand Up @@ -417,6 +443,7 @@ def _key(self):
self.description,
self.fields,
policy_tags,
self.timestamp_precision,
)

def to_standard_sql(self) -> standard_sql.StandardSqlField:
Expand Down Expand Up @@ -467,10 +494,9 @@ def __hash__(self):
return hash(self._key())

def __repr__(self):
key = self._key()
policy_tags = key[-1]
*initial_tags, policy_tags, timestamp_precision_tag = self._key()
policy_tags_inst = None if policy_tags is None else PolicyTagList(policy_tags)
adjusted_key = key[:-1] + (policy_tags_inst,)
adjusted_key = (*initial_tags, policy_tags_inst, timestamp_precision_tag)
return f"{self.__class__.__name__}{adjusted_key}"


Expand Down
20 changes: 20 additions & 0 deletions tests/system/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
]
SCHEMA_PICOSECOND = [
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
bigquery.SchemaField(
"time_pico", "TIMESTAMP", mode="REQUIRED", timestamp_precision=12
),
]
CLUSTERING_SCHEMA = [
bigquery.SchemaField("full_name", "STRING", mode="REQUIRED"),
bigquery.SchemaField("age", "INTEGER", mode="REQUIRED"),
Expand Down Expand Up @@ -631,6 +638,19 @@ def test_create_table_w_time_partitioning_w_clustering_fields(self):
self.assertEqual(time_partitioning.field, "transaction_time")
self.assertEqual(table.clustering_fields, ["user_email", "store_code"])

def test_create_tabl_w_picosecond_timestamp(self):
dataset = self.temp_dataset(_make_dataset_id("create_table"))
table_id = "test_table"
table_arg = Table(dataset.table(table_id), schema=SCHEMA_PICOSECOND)
self.assertFalse(_table_exists(table_arg))

table = helpers.retry_403(Config.CLIENT.create_table)(table_arg)
self.to_delete.insert(0, table)

self.assertTrue(_table_exists(table))
self.assertEqual(table.table_id, table_id)
self.assertEqual(table.schema, SCHEMA_PICOSECOND)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have a test that reads back a timestamp, and makes sure its in the expected range? Or am I misunderstanding?


def test_delete_dataset_with_string(self):
dataset_id = _make_dataset_id("delete_table_true_with_string")
project = Config.CLIENT.project
Expand Down
31 changes: 30 additions & 1 deletion tests/unit/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_constructor_explicit(self):
default_value_expression=FIELD_DEFAULT_VALUE_EXPRESSION,
rounding_mode=enums.RoundingMode.ROUNDING_MODE_UNSPECIFIED,
foreign_type_definition="INTEGER",
timestamp_precision=6,
)
self.assertEqual(field.name, "test")
self.assertEqual(field.field_type, "STRING")
Expand All @@ -87,6 +88,7 @@ def test_constructor_explicit(self):
)
self.assertEqual(field.rounding_mode, "ROUNDING_MODE_UNSPECIFIED")
self.assertEqual(field.foreign_type_definition, "INTEGER")
self.assertEqual(field._properties["timestampPrecision"], 6)

def test_constructor_explicit_none(self):
field = self._make_one("test", "STRING", description=None, policy_tags=None)
Expand Down Expand Up @@ -189,6 +191,23 @@ def test_to_api_repr_with_subfield(self):
},
)

def test_to_api_repr_w_timestamp_precision(self):
field = self._make_one(
"foo",
"TIMESTAMP",
"NULLABLE",
timestamp_precision=6,
)
self.assertEqual(
field.to_api_repr(),
{
"mode": "NULLABLE",
"name": "foo",
"type": "TIMESTAMP",
"timestampPrecision": 6,
},
)

def test_from_api_repr(self):
field = self._get_target_class().from_api_repr(
{
Expand All @@ -198,6 +217,7 @@ def test_from_api_repr(self):
"name": "foo",
"type": "record",
"roundingMode": "ROUNDING_MODE_UNSPECIFIED",
"timestampPrecision": 6,
}
)
self.assertEqual(field.name, "foo")
Expand All @@ -210,6 +230,7 @@ def test_from_api_repr(self):
self.assertEqual(field.fields[0].mode, "NULLABLE")
self.assertEqual(field.range_element_type, None)
self.assertEqual(field.rounding_mode, "ROUNDING_MODE_UNSPECIFIED")
self.assertEqual(field._properties["timestampPrecision"], 6)

def test_from_api_repr_policy(self):
field = self._get_target_class().from_api_repr(
Expand Down Expand Up @@ -323,6 +344,12 @@ def test_foreign_type_definition_property_str(self):
schema_field._properties["foreignTypeDefinition"] = FOREIGN_TYPE_DEFINITION
self.assertEqual(schema_field.foreign_type_definition, FOREIGN_TYPE_DEFINITION)

def test_timestamp_precision_property(self):
TIMESTAMP_PRECISION = 6
schema_field = self._make_one("test", "TIMESTAMP")
schema_field._properties["timestampPrecision"] = TIMESTAMP_PRECISION
self.assertEqual(schema_field.timestamp_precision, TIMESTAMP_PRECISION)

def test_to_standard_sql_simple_type(self):
examples = (
# a few legacy types
Expand Down Expand Up @@ -637,7 +664,9 @@ def test___hash__not_equals(self):

def test___repr__(self):
field1 = self._make_one("field1", "STRING")
expected = "SchemaField('field1', 'STRING', 'NULLABLE', None, None, (), None)"
expected = (
"SchemaField('field1', 'STRING', 'NULLABLE', None, None, (), None, None)"
)
self.assertEqual(repr(field1), expected)

def test___repr__evaluable_no_policy_tags(self):
Expand Down