How Odoo Stores Dates
Odoo uses two field types for temporal data:
fields.Date— stores a date (no time component) as DATE in PostgreSQLfields.Datetime— stores a timestamp in UTC as TIMESTAMP WITHOUT TIME ZONE in PostgreSQL
The critical rule: all datetimes are stored in UTC. When displayed to users, Odoo converts to the user's timezone (set in their preferences). This conversion happens at the view/widget layer, not in the ORM.
Date Field Operations
from odoo import fields
from datetime import date, timedelta
# Current date (server date, NOT user's local date)
today = fields.Date.today()
# Create from string
d = fields.Date.to_date('2026-03-23')
# Convert to string
s = fields.Date.to_string(date(2026, 3, 23)) # '2026-03-23'
# Date arithmetic
tomorrow = fields.Date.today() + timedelta(days=1)
last_week = fields.Date.today() - timedelta(weeks=1)
# Add months (handles month-end correctly)
from dateutil.relativedelta import relativedelta
next_month = fields.Date.today() + relativedelta(months=1)
end_of_quarter = fields.Date.today() + relativedelta(months=3, day=31)
Datetime Field Operations
from odoo import fields
from datetime import datetime, timedelta
# Current UTC datetime
now = fields.Datetime.now()
# From string
dt = fields.Datetime.to_datetime('2026-03-23 14:30:00')
# To string
s = fields.Datetime.to_string(datetime(2026, 3, 23, 14, 30))
# Arithmetic
one_hour_later = fields.Datetime.now() + timedelta(hours=1)
yesterday = fields.Datetime.now() - timedelta(days=1)
Timezone Conversion
The most common source of bugs is incorrect timezone handling. Here is how to convert between UTC and user timezone:
from pytz import timezone, UTC
def _to_user_tz(self, utc_dt):
"""Convert UTC datetime to user's timezone."""
user_tz = timezone(self.env.user.tz or 'UTC')
return utc_dt.replace(tzinfo=UTC).astimezone(user_tz)
def _to_utc(self, local_dt, tz_name):
"""Convert local datetime to UTC for storage."""
local_tz = timezone(tz_name)
local_aware = local_tz.localize(local_dt)
return local_aware.astimezone(UTC).replace(tzinfo=None)
When you need the current date in the user's timezone (not the server's):
def _user_today(self):
"""Get today's date in the user's timezone."""
user_tz = timezone(self.env.user.tz or 'UTC')
return datetime.now(user_tz).date()
Common Off-by-One Day Bug
The most frequent timezone bug occurs when comparing dates with datetimes across timezone boundaries:
# BUG: at 11pm New York (4am UTC next day),
# fields.Date.today() returns tomorrow's date in UTC
# but the user expects today's date in their timezone.
# WRONG
records = self.search([
('create_date', '>=', fields.Date.today())
])
# RIGHT: convert user's today to UTC datetime range
user_tz = timezone(self.env.user.tz or 'UTC')
local_today_start = datetime.now(user_tz).replace(
hour=0, minute=0, second=0)
utc_start = local_today_start.astimezone(UTC).replace(tzinfo=None)
records = self.search([
('create_date', '>=', utc_start)
])
context_today and context_timestamp
Odoo provides context-aware date helpers:
# Get today in the user's timezone
local_today = fields.Date.context_today(self)
# Convert a UTC datetime to user's timezone
local_dt = fields.Datetime.context_timestamp(self, utc_datetime)
Always use context_today() instead of Date.today() when the result will be shown to users or compared with user-facing dates.
Date Formatting in QWeb
<!-- In QWeb templates and reports -->
<span t-field="record.create_date"
t-options='{"format": "dd MMM yyyy"}'/>
<span t-field="record.date_deadline"
t-options='{"widget": "date"}'/>
<!-- In email templates -->
<p>Date: {{ format_date(object.date_deadline) }}</p>
<p>Time: {{ format_datetime(object.create_date,
tz=object.partner_id.tz) }}</p>
Date Ranges and Domains
# This month
first_day = fields.Date.today().replace(day=1)
last_day = first_day + relativedelta(months=1, days=-1)
# Records created this month
records = self.search([
('create_date', '>=', first_day),
('create_date', '<=', last_day),
])
# Last 30 days
records = self.search([
('create_date', '>=',
fields.Datetime.now() - timedelta(days=30)),
])
Deadline and Overdue Patterns
is_overdue = fields.Boolean(
compute='_compute_is_overdue', store=True)
@api.depends('date_deadline')
def _compute_is_overdue(self):
today = fields.Date.context_today(self)
for record in self:
record.is_overdue = (
record.date_deadline and
record.date_deadline < today)
Duration and Time Differences
# Calculate hours between two datetimes
if record.start_date and record.end_date:
delta = record.end_date - record.start_date
hours = delta.total_seconds() / 3600
record.duration_hours = round(hours, 2)
Cron Job Date Considerations
Cron jobs run in UTC context with no user timezone. When a cron job needs timezone-aware logic:
def _cron_check_deadlines(self):
# Use a specific timezone or iterate per-customer
for customer in customers:
tz = timezone(customer.tz or 'UTC')
local_today = datetime.now(tz).date()
# Check deadlines against customer's local date
Best Practices Summary
- Always store datetimes in UTC (Odoo handles this automatically)
- Use
context_today()for user-facing date comparisons, notDate.today() - Use
context_timestamp()when displaying datetimes to users in Python - Use
relativedeltafor month/year arithmetic (not timedelta) - Never assume the server timezone matches any user's timezone
- Test with users in timezones far from UTC (e.g., US/Pacific at -8, Asia/Tokyo at +9)