Repository: googleapis/python-bigquery
Pull request #2231 (2025-07-07 17:53:20)

feat: adds date_format to load job and external config
This commit introduces new configuration options for BigQuery load jobs and external table definitions, aligning with recent updates to the underlying protos.

New options added:

`date_format`: Date format used for parsing DATE values. (Applies to `LoadJobConfig`, `LoadJob`, and `ExternalConfig`)

Changes include:

Added corresponding properties (getters/setters) to `LoadJobConfig`, `LoadJob`, and `ExternalConfig`.
Updated docstrings and type hints for all new attributes.
Updated unit tests to cover the new options, ensuring they are correctly handled during object initialization, serialization to API representation, and deserialization from API responses.


Patch:
diff --git a/google/cloud/bigquery/external_config.py b/google/cloud/bigquery/external_config.py
index fcfcaca2..54b7bf39 100644
--- a/google/cloud/bigquery/external_config.py
+++ b/google/cloud/bigquery/external_config.py
@@ -848,6 +848,20 @@ class ExternalConfig(object):
             prop = {"fields": [field.to_api_repr() for field in value]}
         self._properties["schema"] = prop
 
+    @property
+    def date_format(self) -> Optional[str]:
+        """Optional[str]: Format used to parse DATE values. Supports C-style and SQL-style values.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/tables#ExternalDataConfiguration.FIELDS.date_format
+        """
+        result = self._properties.get("dateFormat")
+        return typing.cast(str, result)
+
+    @date_format.setter
+    def date_format(self, value: Optional[str]):
+        self._properties["dateFormat"] = value
+
     @property
     def time_zone(self) -> Optional[str]:
         """Optional[str]: Time zone used when parsing timestamp values that do not
diff --git a/google/cloud/bigquery/job/load.py b/google/cloud/bigquery/job/load.py
index 5d49aef1..277478d8 100644
--- a/google/cloud/bigquery/job/load.py
+++ b/google/cloud/bigquery/job/load.py
@@ -548,6 +548,19 @@ class LoadJobConfig(_JobConfig):
     def source_format(self, value):
         self._set_sub_prop("sourceFormat", value)
 
+    @property
+    def date_format(self) -> Optional[str]:
+        """Optional[str]: Date format used for parsing DATE values.
+
+        See:
+        https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobConfigurationLoad.FIELDS.date_format
+        """
+        return self._get_sub_prop("dateFormat")
+
+    @date_format.setter
+    def date_format(self, value: Optional[str]):
+        self._set_sub_prop("dateFormat", value)
+
     @property
     def time_zone(self) -> Optional[str]:
         """Optional[str]: Default time zone that will apply when parsing timestamp
@@ -903,6 +916,13 @@ class LoadJob(_AsyncJob):
         """
         return self.configuration.clustering_fields
 
+    @property
+    def date_format(self):
+        """See
+        :attr:`google.cloud.bigquery.job.LoadJobConfig.date_format`.
+        """
+        return self.configuration.date_format
+
     @property
     def time_zone(self):
         """See


Test patch:
diff --git a/tests/unit/job/test_load.py b/tests/unit/job/test_load.py
index 81d8e44b..82baa03c 100644
--- a/tests/unit/job/test_load.py
+++ b/tests/unit/job/test_load.py
@@ -37,14 +37,14 @@ class TestLoadJob(_Base):
         self.OUTPUT_BYTES = 23456
         self.OUTPUT_ROWS = 345
         self.REFERENCE_FILE_SCHEMA_URI = "gs://path/to/reference"
-
+        self.DATE_FORMAT = "%Y-%m-%d"
         self.TIME_ZONE = "UTC"
 
     def _make_resource(self, started=False, ended=False):
         resource = super(TestLoadJob, self)._make_resource(started, ended)
         config = resource["configuration"]["load"]
         config["sourceUris"] = [self.SOURCE1]
-
+        config["dateFormat"] = self.DATE_FORMAT
         config["timeZone"] = self.TIME_ZONE
         config["destinationTable"] = {
             "projectId": self.PROJECT,
@@ -147,7 +147,6 @@ class TestLoadJob(_Base):
             )
         else:
             self.assertIsNone(job.reference_file_schema_uri)
-
         if "destinationEncryptionConfiguration" in config:
             self.assertIsNotNone(job.destination_encryption_configuration)
             self.assertEqual(
@@ -156,6 +155,10 @@ class TestLoadJob(_Base):
             )
         else:
             self.assertIsNone(job.destination_encryption_configuration)
+        if "dateFormat" in config:
+            self.assertEqual(job.date_format, config["dateFormat"])
+        else:
+            self.assertIsNone(job.date_format)
         if "timeZone" in config:
             self.assertEqual(job.time_zone, config["timeZone"])
         else:
@@ -202,7 +205,7 @@ class TestLoadJob(_Base):
         self.assertIsNone(job.clustering_fields)
         self.assertIsNone(job.schema_update_options)
         self.assertIsNone(job.reference_file_schema_uri)
-
+        self.assertIsNone(job.date_format)
         self.assertIsNone(job.time_zone)
 
     def test_ctor_w_config(self):
@@ -599,6 +602,7 @@ class TestLoadJob(_Base):
                 ]
             },
             "schemaUpdateOptions": [SchemaUpdateOption.ALLOW_FIELD_ADDITION],
+            "dateFormat": self.DATE_FORMAT,
             "timeZone": self.TIME_ZONE,
         }
         RESOURCE["configuration"]["load"] = LOAD_CONFIGURATION
@@ -628,7 +632,7 @@ class TestLoadJob(_Base):
         config.write_disposition = WriteDisposition.WRITE_TRUNCATE
         config.schema_update_options = [SchemaUpdateOption.ALLOW_FIELD_ADDITION]
         config.reference_file_schema_uri = "gs://path/to/reference"
-
+        config.date_format = self.DATE_FORMAT
         config.time_zone = self.TIME_ZONE
 
         with mock.patch(
diff --git a/tests/unit/job/test_load_config.py b/tests/unit/job/test_load_config.py
index 6424f7e6..5b7f8175 100644
--- a/tests/unit/job/test_load_config.py
+++ b/tests/unit/job/test_load_config.py
@@ -828,6 +828,22 @@ class TestLoadJobConfig(_Base):
             config._properties["load"]["writeDisposition"], write_disposition
         )
 
+    def test_date_format_missing(self):
+        config = self._get_target_class()()
+        self.assertIsNone(config.date_format)
+
+    def test_date_format_hit(self):
+        date_format = "%Y-%m-%d"
+        config = self._get_target_class()()
+        config._properties["load"]["dateFormat"] = date_format
+        self.assertEqual(config.date_format, date_format)
+
+    def test_date_format_setter(self):
+        date_format = "YYYY/MM/DD"
+        config = self._get_target_class()()
+        config.date_format = date_format
+        self.assertEqual(config._properties["load"]["dateFormat"], date_format)
+
     def test_time_zone_missing(self):
         config = self._get_target_class()()
         self.assertIsNone(config.time_zone)
@@ -942,6 +958,7 @@ class TestLoadJobConfig(_Base):
             },
             "useAvroLogicalTypes": True,
             "writeDisposition": "WRITE_TRUNCATE",
+            "dateFormat": "%Y-%m-%d",
             "timeZone": "America/New_York",
             "parquetOptions": {"enableListInference": True},
             "columnNameCharacterMap": "V2",
@@ -983,6 +1000,7 @@ class TestLoadJobConfig(_Base):
         )
         self.assertTrue(config.use_avro_logical_types)
         self.assertEqual(config.write_disposition, WriteDisposition.WRITE_TRUNCATE)
+        self.assertEqual(config.date_format, "%Y-%m-%d")
         self.assertEqual(config.time_zone, "America/New_York")
         self.assertTrue(config.parquet_options.enable_list_inference)
         self.assertEqual(config.column_name_character_map, ColumnNameCharacterMap.V2)
@@ -1017,6 +1035,7 @@ class TestLoadJobConfig(_Base):
         )
         config.use_avro_logical_types = True
         config.write_disposition = WriteDisposition.WRITE_TRUNCATE
+        config.date_format = "%Y-%m-%d"
         config.time_zone = "America/New_York"
         parquet_options = ParquetOptions()
         parquet_options.enable_list_inference = True
diff --git a/tests/unit/test_external_config.py b/tests/unit/test_external_config.py
index a89b7a1f..0f5d0950 100644
--- a/tests/unit/test_external_config.py
+++ b/tests/unit/test_external_config.py
@@ -25,7 +25,7 @@ import pytest
 
 class TestExternalConfig(unittest.TestCase):
     SOURCE_URIS = ["gs://foo", "gs://bar"]
-
+    DATE_FORMAT = "MM/DD/YYYY"
     TIME_ZONE = "America/Los_Angeles"
 
     BASE_RESOURCE = {
@@ -35,6 +35,7 @@ class TestExternalConfig(unittest.TestCase):
         "autodetect": True,
         "ignoreUnknownValues": False,
         "compression": "compression",
+        "dateFormat": DATE_FORMAT,
         "timeZone": TIME_ZONE,
     }
 
@@ -82,6 +83,7 @@ class TestExternalConfig(unittest.TestCase):
         ec.connection_id = "path/to/connection"
         ec.schema = [schema.SchemaField("full_name", "STRING", mode="REQUIRED")]
 
+        ec.date_format = self.DATE_FORMAT
         ec.time_zone = self.TIME_ZONE
         exp_schema = {
             "fields": [{"name": "full_name", "type": "STRING", "mode": "REQUIRED"}]
@@ -96,6 +98,7 @@ class TestExternalConfig(unittest.TestCase):
             "compression": "compression",
             "connectionId": "path/to/connection",
             "schema": exp_schema,
+            "dateFormat": self.DATE_FORMAT,
             "timeZone": self.TIME_ZONE,
         }
         self.assertEqual(got_resource, exp_resource)
@@ -132,7 +135,7 @@ class TestExternalConfig(unittest.TestCase):
         self.assertEqual(ec.ignore_unknown_values, False)
         self.assertEqual(ec.max_bad_records, 17)
         self.assertEqual(ec.source_uris, self.SOURCE_URIS)
-
+        self.assertEqual(ec.date_format, self.DATE_FORMAT)
         self.assertEqual(ec.time_zone, self.TIME_ZONE)
 
     def test_to_api_repr_source_format(self):