Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ env/
gspread.egg-info/
dist/
docs/build/

# local virtualenv
venv/
41 changes: 41 additions & 0 deletions docs/user-guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,47 @@ Update a range
worksheet.update([[1, 2], [3, 4]], 'A1:B2')


Serializing Values the Standard ``json`` Module Cannot Encode
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

gspread encodes request bodies with the standard library ``json`` module,
which cannot encode some types such as ``datetime`` or ``Decimal``. The
data-writing methods (``update``, ``batch_update``, ``append_row`` and
``append_rows``) accept a ``default_serializer`` argument for these cases. It
works exactly like the ``default`` argument of :func:`json.dumps`: it receives
a value the encoder cannot handle and returns a JSON-serializable substitute.

.. code:: python

import datetime

worksheet.update(
[[datetime.date(2026, 6, 23)]],
"A1",
default_serializer=str,
)

``str`` covers common types like ``datetime``, ``date``, ``Decimal`` and
``UUID``. For finer control, pass your own callable:

.. code:: python

def encode(value):
if isinstance(value, datetime.datetime):
return value.isoformat()
raise TypeError(f"cannot serialize {type(value)}")

worksheet.update([[some_value]], "A1", default_serializer=encode)

.. note::

The substitute you return determines how the value lands in the cell. For
example ``default_serializer=str`` sends a ``Decimal`` as the JSON string
``"19.99"`` (stored as text unless ``value_input_option`` parses it),
whereas returning ``float(value)`` sends a number. Choose the form that
matches how you want the cell interpreted.


Adding Data Validation
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
33 changes: 27 additions & 6 deletions gspread/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

import time
from http import HTTPStatus
from json import dumps as json_dumps
from typing import (
IO,
Any,
Callable,
Dict,
List,
Mapping,
Expand Down Expand Up @@ -44,6 +46,7 @@
from .utils import ExportFormat, convert_credentials, quote

ParamsType = MutableMapping[str, Optional[Union[str, int, bool, float, List[str]]]]
DefaultSerializerType = Optional[Callable[[Any], Any]]

FileType = Optional[
Union[
Expand Down Expand Up @@ -107,11 +110,16 @@ def request(
method: str,
endpoint: str,
params: Optional[ParamsType] = None,
data: Optional[bytes] = None,
data: Optional[Union[str, bytes]] = None,
json: Optional[Mapping[str, Any]] = None,
files: FileType = None,
headers: Optional[MutableMapping[str, str]] = None,
default_serializer: DefaultSerializerType = None,
) -> Response:
if default_serializer is not None and json is not None:
data = json_dumps(dict(json), default=default_serializer)
json = None
headers = {**(headers or {}), "Content-Type": "application/json"}
response = self.session.request(
method=method,
url=endpoint,
Expand Down Expand Up @@ -147,6 +155,7 @@ def values_update(
range: str,
params: Optional[ParamsType] = None,
body: Optional[Mapping[str, Any]] = None,
default_serializer: DefaultSerializerType = None,
) -> Any:
"""Lower-level method that directly calls `PUT spreadsheets/<ID>/values/<range> <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update>`_.

Expand All @@ -171,11 +180,18 @@ def values_update(
.. versionadded:: 3.0
"""
url = SPREADSHEET_VALUES_URL % (id, quote(range))
r = self.request("put", url, params=params, json=body)
r = self.request(
"put", url, params=params, json=body, default_serializer=default_serializer
)
return r.json()

def values_append(
self, id: str, range: str, params: ParamsType, body: Optional[Mapping[str, Any]]
self,
id: str,
range: str,
params: ParamsType,
body: Optional[Mapping[str, Any]],
default_serializer: DefaultSerializerType = None,
) -> Any:
"""Lower-level method that directly calls `spreadsheets/<ID>/values:append <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append>`_.

Expand All @@ -189,7 +205,9 @@ def values_append(
.. versionadded:: 3.0
"""
url = SPREADSHEET_VALUES_APPEND_URL % (id, quote(range))
r = self.request("post", url, params=params, json=body)
r = self.request(
"post", url, params=params, json=body, default_serializer=default_serializer
)
return r.json()

def values_clear(self, id: str, range: str) -> Any:
Expand Down Expand Up @@ -257,7 +275,10 @@ def values_batch_get(
return r.json()

def values_batch_update(
self, id: str, body: Optional[Mapping[str, Any]] = None
self,
id: str,
body: Optional[Mapping[str, Any]] = None,
default_serializer: DefaultSerializerType = None,
) -> Any:
"""Lower-level method that directly calls `spreadsheets/<ID>/values:batchUpdate <https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate>`_.

Expand All @@ -266,7 +287,7 @@ def values_batch_update(
:rtype: dict
"""
url = SPREADSHEET_VALUES_BATCH_UPDATE_URL % id
r = self.request("post", url, json=body)
r = self.request("post", url, json=body, default_serializer=default_serializer)
return r.json()

def spreadsheets_get(self, id: str, params: Optional[ParamsType] = None) -> Any:
Expand Down
42 changes: 39 additions & 3 deletions gspread/worksheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

from .cell import Cell
from .exceptions import GSpreadException
from .http_client import HTTPClient, ParamsType
from .http_client import DefaultSerializerType, HTTPClient, ParamsType
from .urls import WORKSHEET_DRIVE_URL
from .utils import (
DateTimeOption,
Expand Down Expand Up @@ -1115,6 +1115,7 @@ def update(
include_values_in_response: Optional[bool] = None,
response_value_render_option: Optional[ValueRenderOption] = None,
response_date_time_render_option: Optional[DateTimeOption] = None,
default_serializer: DefaultSerializerType = None,
) -> JSONResponse:
"""Sets values in a cell range of the sheet.

Expand Down Expand Up @@ -1195,6 +1196,13 @@ def update(
The default ``date_time_render_option`` is ``DateTimeOption.serial_number``.
:type date_time_render_option: :class:`~gspread.utils.DateTimeOption`

:param default_serializer: (optional) A callable applied to values that
the standard ``json`` encoder cannot serialize on its own (e.g.
``datetime`` or ``Decimal``). It receives the offending value and
must return a JSON-serializable substitute, mirroring the ``default``
argument of :func:`json.dumps`. For example, ``default_serializer=str``.
:type default_serializer: Callable[[Any], Any]

Examples::

# Sets 'Hello world' in 'A2' cell
Expand Down Expand Up @@ -1248,6 +1256,7 @@ def update(
full_range_name,
params=params,
body={"values": values, "majorDimension": major_dimension},
default_serializer=default_serializer,
)

return response
Expand All @@ -1260,6 +1269,7 @@ def batch_update(
include_values_in_response: Optional[bool] = None,
response_value_render_option: Optional[ValueRenderOption] = None,
response_date_time_render_option: Optional[DateTimeOption] = None,
default_serializer: DefaultSerializerType = None,
) -> JSONResponse:
"""Sets values in one or more cell ranges of the sheet at once.

Expand Down Expand Up @@ -1333,6 +1343,13 @@ def batch_update(
The default ``date_time_render_option`` is ``DateTimeOption.serial_number``.
:type date_time_render_option: :class:`~gspread.utils.DateTimeOption`

:param default_serializer: (optional) A callable applied to values that
the standard ``json`` encoder cannot serialize on its own (e.g.
``datetime`` or ``Decimal``). It receives the offending value and
must return a JSON-serializable substitute, mirroring the ``default``
argument of :func:`json.dumps`. For example, ``default_serializer=str``.
:type default_serializer: Callable[[Any], Any]

Examples::

worksheet.batch_update([{
Expand Down Expand Up @@ -1365,7 +1382,9 @@ def batch_update(
"data": data,
}

response = self.client.values_batch_update(self.spreadsheet_id, body=body)
response = self.client.values_batch_update(
self.spreadsheet_id, body=body, default_serializer=default_serializer
)

return response

Expand Down Expand Up @@ -1785,6 +1804,7 @@ def append_row(
insert_data_option: Optional[InsertDataOption] = None,
table_range: Optional[str] = None,
include_values_in_response: bool = False,
default_serializer: DefaultSerializerType = None,
) -> JSONResponse:
"""Adds a row to the worksheet and populates it with values.

Expand All @@ -1804,6 +1824,12 @@ def append_row(
:param bool include_values_in_response: (optional) Determines if the
update response should include the values of the cells that were
appended. By default, responses do not include the updated values.
:param default_serializer: (optional) A callable applied to values that
the standard ``json`` encoder cannot serialize on its own (e.g.
``datetime`` or ``Decimal``). It receives the offending value and
must return a JSON-serializable substitute, mirroring the ``default``
argument of :func:`json.dumps`. For example, ``default_serializer=str``.
:type default_serializer: Callable[[Any], Any]

.. _ValueInputOption: https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption
.. _InsertDataOption: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption
Expand All @@ -1815,6 +1841,7 @@ def append_row(
insert_data_option=insert_data_option,
table_range=table_range,
include_values_in_response=include_values_in_response,
default_serializer=default_serializer,
)

def append_rows(
Expand All @@ -1824,6 +1851,7 @@ def append_rows(
insert_data_option: Optional[InsertDataOption] = None,
table_range: Optional[str] = None,
include_values_in_response: Optional[bool] = None,
default_serializer: DefaultSerializerType = None,
) -> JSONResponse:
"""Adds multiple rows to the worksheet and populates them with values.

Expand All @@ -1845,6 +1873,12 @@ def append_rows(
:param bool include_values_in_response: (optional) Determines if the
update response should include the values of the cells that were
appended. By default, responses do not include the updated values.
:param default_serializer: (optional) A callable applied to values that
the standard ``json`` encoder cannot serialize on its own (e.g.
``datetime`` or ``Decimal``). It receives the offending value and
must return a JSON-serializable substitute, mirroring the ``default``
argument of :func:`json.dumps`. For example, ``default_serializer=str``.
:type default_serializer: Callable[[Any], Any]

.. _ValueInputOption: https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption
.. _InsertDataOption: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption
Expand All @@ -1859,7 +1893,9 @@ def append_rows(

body = {"values": values}

res = self.client.values_append(self.spreadsheet_id, range_label, params, body)
res = self.client.values_append(
self.spreadsheet_id, range_label, params, body, default_serializer
)
num_new_rows = len(values)
self._properties["gridProperties"]["rowCount"] += num_new_rows
return res
Expand Down
103 changes: 103 additions & 0 deletions tests/http_client_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import datetime
import json
from unittest import TestCase
from unittest.mock import Mock

from gspread.http_client import HTTPClient
from gspread.worksheet import Worksheet


class HTTPClientSerializerTest(TestCase):
def _make_client(self):
session = Mock()
session.request.return_value.ok = True # so request() returns, doesn't raise
return HTTPClient(auth=None, session=session), session

def test_default_uses_json_kwarg(self):
"""Without default_serializer, the body goes out via json= (unchanged behavior)."""
client, session = self._make_client()
body = {"values": [[1, 2, 3]]}

client.request("post", "http://example.com", json=body)

_, kwargs = session.request.call_args
self.assertEqual(kwargs["json"], body)
self.assertIsNone(kwargs["data"])

def test_default_serializer_uses_data_and_header(self):
"""With default_serializer, the body is encoded into data= with a JSON header."""
client, session = self._make_client()
body = {"values": [[1, 2, 3]]}

client.request("post", "http://example.com", json=body, default_serializer=str)

_, kwargs = session.request.call_args
self.assertIsNone(kwargs["json"])
self.assertEqual(kwargs["data"], json.dumps(body, default=str))
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")

def test_default_serializer_handles_non_native_types(self):
"""The actual use case from the issue: encode a date the stdlib can't."""
client, session = self._make_client()
body = {"values": [[datetime.date(2026, 6, 19)]]}

client.request(
"post",
"http://example.com",
json=body,
default_serializer=lambda o: o.isoformat(),
)

_, kwargs = session.request.call_args
self.assertIn("2026-06-19", kwargs["data"])


class WorksheetForwardsSerializerTest(TestCase):
"""default_serializer must reach the client from each write method."""

MARKER = staticmethod(lambda o: str(o))

def _make_worksheet(self):
client = HTTPClient(auth=None, session=Mock())
client.values_update = Mock()
client.values_append = Mock()
client.values_batch_update = Mock()
properties = {
"title": "Sheet1",
"gridProperties": {"rowCount": 5, "columnCount": 5},
}
worksheet = Worksheet(
spreadsheet=None,
properties=properties,
spreadsheet_id="abc123",
client=client,
)
return worksheet, client

def test_update_forwards_serializer(self):
worksheet, client = self._make_worksheet()
worksheet.update([[1, 2, 3]], "A1", default_serializer=self.MARKER)
self.assertIs(
client.values_update.call_args.kwargs["default_serializer"], self.MARKER
)

def test_batch_update_forwards_serializer(self):
worksheet, client = self._make_worksheet()
worksheet.batch_update(
[{"range": "A1", "values": [[1]]}], default_serializer=self.MARKER
)
self.assertIs(
client.values_batch_update.call_args.kwargs["default_serializer"],
self.MARKER,
)

def test_append_rows_forwards_serializer(self):
worksheet, client = self._make_worksheet()
worksheet.append_rows([[1, 2, 3]], default_serializer=self.MARKER)
# values_append receives default_serializer as the last positional arg
self.assertIs(client.values_append.call_args.args[4], self.MARKER)

def test_append_row_forwards_serializer(self):
worksheet, client = self._make_worksheet()
worksheet.append_row([1, 2, 3], default_serializer=self.MARKER)
self.assertIs(client.values_append.call_args.args[4], self.MARKER)
Loading