Computing DSR Deadlines Across Time Zones

A Data Subject Request (DSR) deadline is a legal fact, not a wall-clock convenience, and the most common way engineers get it wrong is to add a fixed number of days to a naive timestamp and hope the server’s timezone matches the regulator’s. Within the broader 30-Day vs 45-Day SLA Mapping framework — itself part of the wider DSR Architecture & Intake Routing pipeline — this page fixes the temporal arithmetic that turns an attestation instant into a defensible deadline across jurisdictions and daylight-saving boundaries. The teams who hit this are privacy and platform engineers whose intake service runs in UTC but whose data subjects, reviewers, and regulators sit in Europe/London, America/Los_Angeles, and everywhere between; the failure mode is a deadline computed a full day early or late, which either manufactures a breach or hides a real one. The subtle trap is that GDPR Art. 12(3) counts a calendar month, not thirty days, while CCPA §1798.130(a)(2) counts 45 calendar days — two different arithmetics that must both be anchored to the same UTC instant. Once the deadline exists, granting more time is a separate concern handled in the sibling Implementing SLA Extension Workflows guide.

The two frameworks branch on arithmetic — calendar-month addition versus a fixed day count — but converge on one UTC-anchored, end-of-day deadline.

Computing a DSR deadline from an attestation instant across two frameworks An attestation timestamp, required to be timezone-aware, is first normalized to UTC. It then reaches a framework decision. The GDPR path adds one calendar month using relativedelta, then passes through a month-end clamp that maps an anchor like January 31 to the last valid day of the following month, February 28 or 29. The CCPA and CPRA path instead adds a fixed 45 calendar days. Both paths converge on an end-of-day boundary applied in UTC and stored as a TIMESTAMPTZ value, which becomes the single deadline every downstream escalation rule reads. Aware timestamp→ astimezone(UTC) GDPR — one calendar monthrelativedelta(months=+1) Month-end clampJan 31 + 1mo → Feb 28/29 CCPA / CPRAtimedelta(days=45) End-of-day, UTCTIMESTAMPTZ framework? one month 45 days
One attestation instant, two statutory arithmetics: GDPR adds a calendar month with month-end clamping, CCPA adds 45 fixed days, and both resolve to the same UTC end-of-day deadline.

Prerequisites

This implementation targets Python 3.11+ for the standard-library zoneinfo module and modern generic syntax. You will need:

  • pydantic (v2) — strict validation of the attestation record at the boundary, using v2 idioms (model_config = ConfigDict(...), @field_validator). Pydantic v1 constructs (class Config, @validator, .dict()) will not work.
  • python-dateutilrelativedelta supplies calendar-aware month arithmetic that timedelta cannot express. A timedelta has no concept of “a month”; only day-based deltas.
  • zoneinfo (stdlib) — the IANA timezone database for converting an aware timestamp to UTC and, where required, rendering an end-of-day boundary in a controller’s local zone.

The one non-negotiable input contract is that the attestation timestamp must be timezone-aware. This page assumes a verified attested_at produced upstream by the Secure Intake Form Design flow; it never accepts a naive datetime, because a naive value silently inherits whatever the process thinks “local” is and corrupts every deadline computed from it.

Step-by-Step Implementation

Step 1 — Model the attestation instant and reject naive datetimes

The deadline is only as trustworthy as its anchor, so the anchor is validated first. A Pydantic v2 model enforces timezone-awareness at construction and refuses to hold a value that cannot be reasoned about in UTC.

from datetime import datetime
from enum import Enum
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, field_validator

UTC = ZoneInfo("UTC")


class Framework(str, Enum):
    GDPR = "GDPR"
    CCPA = "CCPA"
    CPRA = "CPRA"


class Attestation(BaseModel):
    """The verified-receipt instant the statutory clock is anchored to."""

    model_config = ConfigDict(extra="forbid", frozen=True)

    request_id: str
    framework: Framework
    attested_at: datetime

    @field_validator("attested_at")
    @classmethod
    def must_be_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("attested_at must be timezone-aware (GDPR Art. 12(3))")
        return v.astimezone(UTC)

Normalizing to UTC inside the validator means every consumer of the model reads a single canonical instant, regardless of the offset the request arrived with.

Step 2 — Compute the deadline with framework-correct arithmetic

The two frameworks demand two different additions. GDPR Art. 12(3) requires action “within one month” of a verifiable request, which the ICO right of access guidance interprets as a calendar month: the deadline is the corresponding date in the next month, and where no such date exists the last day of that month is used. CCPA §1798.130(a)(2) instead fixes the window at 45 calendar days from receipt of a verifiable request — a fixed count that timedelta expresses exactly.

from datetime import datetime, timedelta

from dateutil.relativedelta import relativedelta


def deadline(framework: Framework, attested_at: datetime) -> datetime:
    """Map a UTC attestation instant to its statutory deadline.

    GDPR uses calendar-month arithmetic (with month-end clamping handled
    natively by relativedelta); CCPA/CPRA use a fixed 45 calendar days.
    """
    if attested_at.tzinfo is None:
        raise ValueError("attested_at must be timezone-aware")
    anchor = attested_at.astimezone(UTC)

    if framework is Framework.GDPR:
        # relativedelta clamps Jan 31 + 1 month to Feb 28/29 automatically.
        return anchor + relativedelta(months=+1)
    # CCPA and CPRA: 45 calendar days, no month semantics.
    return anchor + timedelta(days=45)

relativedelta(months=+1) does the month-end clamp for free: adding a month to 2026-01-31 yields 2026-02-28, and to 2028-01-31 yields 2028-02-29. This is exactly the regulator’s rule, and it is why a naive timedelta(days=30) — the conservative shortcut used at the parent level — is safe but not exact: 30 days from January 31 lands on March 2, an earlier and therefore non-breaching date, but not the legally correct one.

Step 3 — Apply the end-of-day boundary

A deadline expressed as an instant implies the request is late one second after it. Regulators reason in whole days, so the operative boundary is the end of the deadline date, not the same clock time a month later. Which day-end applies is a policy decision: the conservative, defensible choice is end-of-day in UTC, which never grants more time than the local-day interpretation would in a positive-offset zone.

from datetime import time


def end_of_day_utc(dt: datetime) -> datetime:
    """Push a deadline to 23:59:59 on its own date, in UTC."""
    return datetime.combine(dt.date(), time(23, 59, 59), tzinfo=UTC)


def sla_deadline(framework: Framework, attested_at: datetime) -> datetime:
    """Full pipeline: aware anchor → statutory add → end-of-day UTC boundary."""
    return end_of_day_utc(deadline(framework, attested_at))

Step 4 — Bind the result into a validated record

The final deadline is stored alongside the anchor and the timezone it was judged in, so any later audit can replay the computation without guessing which rule ran.

from pydantic import BaseModel, ConfigDict


class DeadlineRecord(BaseModel):
    """Immutable, replayable record of a computed statutory deadline."""

    model_config = ConfigDict(extra="forbid", frozen=True)

    request_id: str
    framework: Framework
    attested_at: datetime
    deadline_utc: datetime
    judged_in_tz: str = "UTC"


def build_record(a: Attestation) -> DeadlineRecord:
    return DeadlineRecord(
        request_id=a.request_id,
        framework=a.framework,
        attested_at=a.attested_at,
        deadline_utc=sla_deadline(a.framework, a.attested_at),
    )

Configuration Reference

Parameter Type Default Compliance note
attested_at datetime required, tz-aware The clock anchor. A naive value is rejected; GDPR Art. 12(3) runs from a verifiable request instant.
framework Framework required Selects calendar-month (GDPR) versus 45-day (CCPA/CPRA) arithmetic.
GDPR add relativedelta months=+1 One calendar month per Art. 12(3); month-end clamped to the last valid day.
CCPA/CPRA add timedelta days=45 Fixed 45 calendar days per §1798.130(a)(2).
judged_in_tz str "UTC" Timezone the end-of-day boundary is applied in; UTC is the conservative default.
end-of-day time 23:59:59 Whole-day interpretation of a statutory deadline; a request is not late until the day ends.

Verification

Correctness lives in the boundary cases regulators care about: month-end anchors, leap years, and daylight-saving transitions. Pin each explicitly so a regression that shifts a deadline fails the build.

from datetime import datetime
from zoneinfo import ZoneInfo

UTC = ZoneInfo("UTC")


def test_gdpr_calendar_month_normal():
    a = datetime(2026, 3, 10, 9, 0, tzinfo=UTC)
    assert sla_deadline(Framework.GDPR, a).date().isoformat() == "2026-04-10"


def test_gdpr_month_end_clamp_non_leap():
    # Jan 31 + one month clamps to Feb 28 in a non-leap year.
    a = datetime(2026, 1, 31, 12, 0, tzinfo=UTC)
    assert sla_deadline(Framework.GDPR, a).date().isoformat() == "2026-02-28"


def test_gdpr_month_end_clamp_leap_year():
    # 2028 is a leap year, so the clamp lands on Feb 29.
    a = datetime(2028, 1, 31, 12, 0, tzinfo=UTC)
    assert sla_deadline(Framework.GDPR, a).date().isoformat() == "2028-02-29"


def test_ccpa_is_fixed_45_days():
    a = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
    assert sla_deadline(Framework.CCPA, a).date().isoformat() == "2026-02-15"


def test_dst_local_anchor_normalizes_to_utc():
    # A London afternoon during BST is still one UTC-anchored instant.
    a = datetime(2026, 6, 20, 15, 0, tzinfo=ZoneInfo("Europe/London"))
    d = sla_deadline(Framework.GDPR, a)
    assert d.tzinfo == UTC and d.date().isoformat() == "2026-07-20"

The CCPA case is the clearest illustration that 45 days is not “a month and a half”: 45 days from January 1 is February 15, whereas a calendar month plus fifteen days would be different. The DST test confirms that an anchor captured in a zone that shifts its offset mid-year still resolves to a stable UTC instant.

Troubleshooting

Naive datetime slips through. Symptom: deadlines land an hour or a day off and vary by deployment host. Cause: an attested_at with tzinfo is None was accepted and interpreted as process-local time. Fix: reject naive datetimes at the model boundary (Step 1) and normalize every anchor with astimezone(UTC) before any arithmetic.

DST “wall-clock” gap or fold corrupts a local render. Symptom: a deadline rendered in a local zone throws or duplicates around a spring-forward/fall-back boundary. Cause: constructing a local datetime during the non-existent or repeated hour. Fix: do all arithmetic in UTC and convert to a local zone only for display; never anchor the computation to a local wall-clock instant.

Month overflow from day-based math. Symptom: a GDPR request anchored on January 31 reports a March deadline. Cause: timedelta(days=30) or days=31 used where a calendar month was required. Fix: use relativedelta(months=+1), which clamps to the last valid day of the target month.

Deadline judged in the wrong timezone. Symptom: an auditor disputes whether a request delivered “on the deadline date” was on time. Cause: end-of-day applied in a local zone that granted extra hours over UTC. Fix: apply the end-of-day boundary in UTC by default and record judged_in_tz so the interpretation is explicit and replayable.

Leap-second or sub-second drift. Symptom: boundary comparisons flap by fractions of a second. Cause: comparing against datetime.now() at microsecond precision. Fix: compare on whole-day boundaries (23:59:59 UTC) rather than exact instants, and pair the calculation with the NTP drift guard described in 30-Day vs 45-Day SLA Mapping.

Frequently Asked Questions

Why use a calendar month for GDPR instead of a fixed 30 days?

Because GDPR Art. 12(3) counts “one month”, which the ICO right of access guidance reads as a calendar month: the corresponding date in the following month, or the last day of that month where no corresponding date exists. A fixed 30-day timedelta is safe because it can only produce an earlier, non-breaching deadline, but it is not the legally exact date. Calendar-month arithmetic via relativedelta(months=+1) yields the correct boundary and clamps month-end anchors automatically.

What happens when an anchor date has no corresponding day next month?

The deadline clamps to the last day of the target month. Adding one month to January 31 yields February 28 in a common year and February 29 in a leap year; relativedelta performs this clamp natively, matching the regulator’s stated rule. This is the single most common month-arithmetic bug when teams roll their own day-based math.

Which timezone is a DSR deadline actually judged in?

Anchor and compute in UTC, and apply the end-of-day boundary in UTC as the conservative default. A deadline is a legal fact that must not depend on the server’s local zone, so normalize the aware attestation instant with astimezone(UTC) before any arithmetic. If policy requires judging the day-end in a controller’s local jurisdiction, record that zone explicitly so the interpretation is auditable rather than implicit.

Does daylight saving time change a DSR deadline?

No, provided the anchor is timezone-aware and normalized to UTC. A London afternoon during British Summer Time and the same instant in winter both resolve to one unambiguous UTC point, and adding a calendar month or 45 days to a UTC instant is unaffected by any local DST transition. Problems only appear if you do arithmetic on a local wall-clock value across a spring-forward gap or fall-back fold.