Working with temporal data is a fundamental aspect of database management, and PostgreSQL offers one of the most dependable sets of tools for handling it. A common requirement developers face is isolating the calendar date from a full TIMESTAMP value, which includes both date and time components down to microseconds. Whether you are generating daily sales reports, partitioning data by day, or simply displaying a user-friendly birthdate, knowing how to efficiently get date from timestamp in PostgreSQL is essential for writing clean, performant queries.
This guide explores the various methods available, ranging from standard SQL casting to PostgreSQL-specific shortcuts, along with performance considerations and timezone nuances that often trip up even experienced engineers.
Understanding the Data Types
Before diving into the syntax, it is crucial to distinguish between the primary temporal data types involved.
TIMESTAMP(orTIMESTAMP WITHOUT TIME ZONE): Stores a date and time (year, month, day, hour, minute, second, fractional seconds) but does not store any timezone information. It represents a "local" time.TIMESTAMPTZ(orTIMESTAMP WITH TIME ZONE): Stores a date and time internally in UTC. When you insert a value with a timezone offset, PostgreSQL converts it to UTC for storage. Upon retrieval, it converts it back to the session'sTimeZonesetting.DATE: Stores only the calendar date (year, month, day) without any time component or timezone awareness.
The conversion logic differs slightly depending on whether your source column is a TIMESTAMP or TIMESTAMPTZ, primarily because of how timezones affect the "current date."
Method 1: The Standard SQL CAST Operator
The most portable and ANSI-SQL compliant way to extract the date is using the CAST function. This syntax works across almost all relational databases (MySQL, SQL Server, Oracle, PostgreSQL), making it the safest choice for cross-database compatibility The details matter here. And it works..
SELECT CAST(created_at AS DATE) AS created_date
FROM events;
How it works:
- For
TIMESTAMP WITHOUT TIME ZONE: It simply truncates the time portion.2023-10-25 14:30:00becomes2023-10-25. - For
TIMESTAMPTZ: It converts the UTC stored value to the current session timezone (defined by theTimeZoneparameter) and then extracts the date.
Why this matters: If your server runs in UTC but your user session is set to America/New_York, a TIMESTAMPTZ value stored as 2023-10-26 01:00:00+00 (UTC) will be cast to 2023-10-25 in New York (which is UTC-4). This is usually the desired behavior for user-facing reports but can cause confusion if you expect the "raw" UTC date Simple as that..
Method 2: The PostgreSQL :: Cast Operator
PostgreSQL provides a historical, shorthand syntax for casting using the double-colon :: operator. It is functionally identical to CAST() but is more concise and idiomatic to Postgres.
SELECT created_at::DATE AS created_date
FROM events;
This is widely preferred in the PostgreSQL community for its readability and brevity. It follows the exact same timezone conversion rules as the standard CAST function when applied to TIMESTAMPTZ.
Method 3: The DATE_TRUNC Function
While casting is the direct way to change the data type, DATE_TRUNC is a powerful function used to truncate a timestamp to a specific precision (year, month, day, hour, etc.). It returns a TIMESTAMP (or TIMESTAMPTZ), not a DATE type, but it effectively "zeros out" the time component.
SELECT DATE_TRUNC('day', created_at) AS day_start
FROM events;
Output: 2023-10-25 00:00:00 (or 2023-10-25 00:00:00+00 for timestamptz).
When to use this:
- When you need to group by day but want to keep the result as a timestamp for further time-series calculations.
- When you need the start of the day (midnight) rather than just the date label.
- It is often faster for
GROUP BYoperations on indexed timestamp columns because the database can potentially use an index on the expressionDATE_TRUNC('day', column)whereas casting toDATEmight require a separate functional index.
Method 4: The EXTRACT Function
The EXTRACT function pulls specific sub-fields (like year, month, day) from a temporal value. It returns a NUMERIC (or DOUBLE PRECISION in older versions), not a DATE type.
SELECT EXTRACT(DAY FROM created_at) AS day_num,
EXTRACT(MONTH FROM created_at) AS month_num,
EXTRACT(YEAR FROM created_at) AS year_num
FROM events;
Use case: This is rarely used to "get the date" as a single object. Instead, it is used when you need to filter or aggregate by specific date parts (e.g., "Show me all records for the 15th of any month" or "Group by ISO week number").
The Critical Timezone Trap (TIMESTAMPTZ vs TIMESTAMP)
This is the single most common source of bugs when extracting dates in PostgreSQL.
Scenario
Imagine a global application. The database server runs in UTC. A user in Los Angeles (UTC-7) creates a record at 2023-11-05 22:00:00 local time Most people skip this — try not to..
- The
TIMESTAMPTZcolumn stores this as2023-11-06 05:00:00+00(UTC). - The
TIMESTAMPcolumn (if used incorrectly) stores2023-11-05 22:00:00(no zone info).
Querying the Date
-- Session TimeZone is 'America/Los_Angeles'
SELECT created_at_tz::DATE, created_at_no_tz::DATE
FROM events;
Results:
created_at_tz::DATE->2023-11-05(Correct for the user). Postgres converts UTC05:00back to LA22:00previous day, then extracts date.created_at_no_tz::DATE->2023-11-05(Coincidentally correct here, but dangerous). It just truncates the stored literal time.
The Danger Zone
Now, run the exact same query from a reporting script where the session TimeZone is set to UTC (default for many drivers) That alone is useful..
Results:
created_at_tz::DATE->2023-11-06. The UTC time is05:00on the 6th. The date flips!created_at_no_tz::DATE->2023-11-05. It remains static because it has no timezone awareness.
Best Practice: Always be explicit about the timezone context when casting TIMESTAMPTZ to DATE. If you want the date in a specific
Best Practice: Always be explicit about the timezone context when casting TIMESTAMPTZ to DATE. If you want the date in a specific zone (e.g., the user's local timezone or a business reporting zone like 'America/New_York'), use the AT TIME ZONE construct before casting:
-- Force interpretation in a specific zone regardless of session setting
SELECT (created_at_tz AT TIME ZONE 'America/Los_Angeles')::DATE AS user_local_date
FROM events;
This converts the TIMESTAMPTZ to a TIMESTAMP WITHOUT TIME ZONE representing the wall-clock time in the target zone, then truncates to the date. This guarantees that 2023-11-06 05:00:00+00 reliably becomes 2023-11-05 for your Los Angeles reports, even if the database session is running in UTC.
Performance & Indexing Considerations
Choosing a method isn't just about syntax—it dictates whether your queries use indexes.
| Method | Expression | Index Strategy |
|---|---|---|
Cast (::DATE) |
created_at::DATE |
Requires a functional index: CREATE INDEX ON events ((created_at::DATE)); |
DATE_TRUNC |
DATE_TRUNC('day', created_at) |
Requires functional index on the exact expression: CREATE INDEX ON events (DATE_TRUNC('day', created_at)); |
EXTRACT |
EXTRACT(DAY FROM created_at) |
Cannot be easily indexed for range scans. Use only for filtering/grouping, not for driving index lookups on large tables. |
Pro Tip: If you frequently query "today's data" or "last 7 days," avoid wrapping the column in a function in the WHERE clause (e.g., WHERE created_at::DATE = CURRENT_DATE). This prevents standard B-Tree index usage unless a matching functional index exists. Instead, use range queries on the raw timestamp column, which are SARGable (Search ARGument Able):
-- Fast: Uses standard index on created_at
WHERE created_at >= '2023-11-05'
AND created_at < '2023-11-06' -- Exclusive upper bound
Summary Cheat Sheet
| Goal | Recommended Syntax | Returns |
|---|---|---|
| Current Date (Session TZ) | CURRENT_DATE |
DATE |
| Current Date (UTC) | (NOW() AT TIME ZONE 'UTC')::DATE |
DATE |
| Strip Time (Keep TZ Context) | timestamptz_col::DATE |
DATE |
| Strip Time (Force Specific TZ) | (timestamptz_col AT TIME ZONE 'Zone')::DATE |
DATE |
| Truncate to Midnight (TS) | DATE_TRUNC('day', timestamp_col) |
TIMESTAMP |
| Truncate to Midnight (TSTZ) | DATE_TRUNC('day', timestamptz_col) |
TIMESTAMPTZ |
| Get Part for Grouping | EXTRACT(DOW FROM date_col) |
NUMERIC |
Conclusion
PostgreSQL’s richness in temporal types is a double-edged sword: it offers precision but demands intent. The "correct" way to get a date depends entirely on what that date represents Easy to understand, harder to ignore..
- Use
CURRENT_DATEfor application logic tied to the user's "today." - Use
::DATEfor simple display or grouping when the session timezone is guaranteed correct. - Use
AT TIME ZONE 'Zone' ::DATEfor canonical business reporting (e.g., "Sales per day in HQ time") to eliminate timezone drift bugs. - Use
DATE_TRUNCwhen you need to preserve the timestamp type for window functions or continuous time-series math. - Use Range Queries (
>=/<) on raw columns for high-performance filtering.
Mastering these distinctions transforms date handling from a source of subtle production bugs into a reliable, predictable foundation for your data layer The details matter here..