Skip to content

Date, Datetime, and Timezone Handling in Odoo: Complete Developer Guide

DeployMonkey Team · March 23, 2026 12 min read

How Odoo Stores Dates

Odoo uses two field types for temporal data:

  • fields.Date — stores a date (no time component) as DATE in PostgreSQL
  • fields.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, not Date.today()
  • Use context_timestamp() when displaying datetimes to users in Python
  • Use relativedelta for 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)