Django date.today() vs timezone.localdate() bug
date.today() reads the OS timezone, not Django's TIME_ZONE. I replaced 104 calls with timezone.localdate() and added a test that keeps them from coming back.
Published: 5 min read
Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

Contents
In BusiKM, TIME_ZONE is set to Europe/Warsaw, and yet some dates in documents were computed in a different timezone. The culprit was date.today(), which reads the operating system's timezone and ignores Django's settings entirely. On October 7 I replaced 104 such calls across 32 files with timezone.localdate() and added a test that won't let them come back.
A bug you can't see on your own machine
Django with USE_TZ = True stores time in UTC, and TIME_ZONE tells it which timezone to use for displaying and computing local dates. timezone.localdate() respects that setting. date.today() comes from the Python standard library and takes the date from the system clock, so it knows nothing about TIME_ZONE.
On my computer in Warsaw both functions return the same value, so the bug is invisible while writing code and during manual testing. In a container running in UTC, the dates differ for a few hours at night: in summer from midnight to 2:00 Polish time, in winter from midnight to 1:00. During that window it's already the next day in Poland, but still the previous day for the system.

A few hours at night sounds harmless until you look at what these dates mean. BusiKM issues invoices, calculates the status of vehicle and driver documents, and decides which month is closed for accounting export. In each of these cases the date is a calendar day in Poland, because that's what the regulations operate on.
An invoice in the wrong month
This is most visible with an invoice for a job. The sale date is an invoice element under Art. 106e of the VAT Act, and the payment term is counted from it in calendar days. The old code took dates from two sources, and both were wrong:
today = date.today() # system time zone
sale_dt = self.completed_at.date() if self.completed_at else today # UTC date
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))date.today() reads the system timezone, and completed_at.date() takes the date from a value stored in UTC. A job closed on May 1 at 01:00 Polish time got a sale date of April 30, so it landed in a different billing month. The fix computes both dates in Polish time:
# Sale date and payment due date are calendar days in Polish time,
# because that is what the VAT act works with.
today = timezone.localdate()
sale_dt = timezone.localdate(self.completed_at) if self.completed_at else today
due_dt = today + timedelta(days=int(self.payment_terms_days or 14))The second pattern, datetime.date() on a value from the database, is easy to miss because it doesn't contain the word today. timezone.localdate(value) converts the value to the timezone from settings before taking the date from it.
The same bug lived in the company document status (valid, expires within 30 days, expired), in the compliance dashboard, in PDF generators, in the issue date on KSeF import when the response didn't include one, and in the check of whether a month is already closed and can be exported to the accounting system. The largest number of calls, 37, was in the demo data script, which never touches client documents. I replaced them along with the rest so that no date.today() is left in the code for anyone to cite later as a precedent.
How to test a bug that shows up at night
A test that checks the date at 12:00 in Warsaw will pass with both the old and the new code. For a test to distinguish the two functions at all, it has to hit the window where the dates differ. You can freeze the clock at 00:30 Polish time, but then the test depends on the timezone of the machine it runs on.
Instead, the test sets the app's timezone to Pacific/Kiritimati, i.e. UTC+14, the furthest-ahead timezone in the world, and freezes the clock at 12:00 UTC. The local date is then already the next day, regardless of the timezone of the machine running CI:
PROBE_TZ = "Pacific/Kiritimati" # UTC+14
FROZEN_UTC_NOON = "2026-04-26 12:00:00+00:00"
@freeze_time(FROZEN_UTC_NOON)
def test_local_date_is_a_day_ahead_of_utc(probe_timezone):
# The probe must actually separate the two dates, otherwise it tests nothing.
assert timezone.now().date() == date(2026, 4, 26)
assert timezone.localdate() == date(2026, 4, 27)The first test in the file checks the probe itself: if it ever stops separating the two dates, the later invoice and billing-period tests would be testing nothing. The following tests check specific places, for example that the sale date on an invoice equals timezone.localdate() rather than the UTC date.
A guard that keeps date.today() out
A hundred calls didn't appear because of a single mistake. It's a habit: date.today() is the first thing that comes to mind, including for the AI models I write most of my code with. Fixing every occurrence isn't enough if the next one shows up next week.
That's why the same file contains a test that walks through the application code and looks for a bare date.today() outside tests and migrations:
def test_application_code_has_no_bare_date_today():
apps_dir = Path(__file__).resolve().parents[2]
bare_call = re.compile(r"(?<![\w.])date\.today\(\)")
offenders = []
for path in apps_dir.rglob("*.py"):
if "tests" in path.parts or "migrations" in path.parts:
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if not line.lstrip().startswith("#") and bare_call.search(line):
offenders.append(f"{path.relative_to(apps_dir.parent)}:{number}")
assert offenders == [], "Use timezone.localdate(): " + ", ".join(offenders)It's a simple grep written as a test, not a linter rule. I chose a test because it runs in the same place as the rest of the tests, the message tells you right away what to use instead, and the list of exceptions (tests, migrations) sits right next to the rule. Its limitation is clear: it won't catch datetime.now().date() or completed_at.date(), the second pattern from the invoice. Those places are guarded by the UTC+14 probe tests, but only where I've written them.
What remains
The documents BusiKM generates are accounting documents, so the date on them is not a display detail. Mileage records must be kept for five years, and an invoice with a sale date in the wrong month means a correction. A timezone bug in such a system is a substantive error, even if it affects only two hours at night.
The UTC+14 probe turned out to be the most useful part. It turns a bug that appears only at night and only in a container into one that shows up in every test run on every machine. If you also compute document dates in Django and want to talk about how to verify them, write to me.
