Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 3 – Data types and built-in functions

Time zones, UTC and timestamps

Store instants in UTC and convert to India Standard Time in PostgreSQL, MySQL and SQLite, read offsets correctly, and avoid the MySQL TIMESTAMP 2038 limit.

  • Intermediate
  • 25 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Explain why applications store UTC instants and convert at the edges
  • Convert between UTC and India Standard Time (UTC+05:30) in each engine
  • Avoid timestamp traps such as the MySQL TIMESTAMP 2038 limit
  • Group UTC timestamps by the calendar day of another time zone

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain why applications store UTC instants and convert at the edges
  • Convert between UTC and India Standard Time (UTC+05:30) in each engine
  • Avoid timestamp traps such as the MySQL TIMESTAMP 2038 limit
  • Group UTC timestamps by the calendar day of another time zone

Before you start

On this page

A timestamp can mean two different things. It can name a moment, the same instant everywhere in the world, or it can be a clock reading, what a wall clock showed somewhere unstated. Applications store moments in UTC, the world’s reference time, and convert them to a local zone only at the edges: when a person types a time in, and when a report shows or groups times for people in one place. India Standard Time (IST) is UTC plus 5 hours 30 minutes all year, so an order placed at 20:00 UTC is at 01:30 IST the next morning, on another calendar day. This lesson converts between UTC and IST in each engine, shows how text with an offset is read, and explains the limit that makes MySQL’s TIMESTAMP stop in the year 2038.

SQLite 3.49.1, running in your browser through sql.js 1.14.2, executes every example in this lesson; how PostgreSQL 18 and MySQL 8.4 handle zones is described from their manuals.

IST and the IANA zone names

Time zones are a matter of law, and their rules change, so software does not hard-code them: it reads the IANA Time Zone Database, which names each zone after a place. India’s zone is Asia/Kolkata. Its history in the database ends with IST, UTC+05:30, from 1945 onwards, after war-time years with an extra hour; India has no daylight saving time, so the offset is the same on every day of the year. Zones with daylight saving, such as Europe/London or America/New_York, change their offset twice a year, which is why a named zone is safer than a fixed offset whenever data from other countries is involved. PostgreSQL uses the IANA data for its zone names, and MySQL can load it into its time zone tables.

UTC and IST, side by side

SQLite knows no zone names and has no zone-aware type: a timestamp is text, and the modifier '+05:30' shifts it by 5 hours 30 minutes.

Four order times on the UTC and IST clocks SQL · utc_and_ist.sql
-- One moment on two clocks: UTC, as stored, and India Standard Time (UTC+05:30), as shown.
CREATE TABLE orders (id INTEGER, placed_utc TEXT);
INSERT INTO orders VALUES
  (1, '2026-03-31 12:00:00'), (2, '2026-03-31 18:29:59'),
  (3, '2026-03-31 18:30:00'), (4, '2026-03-31 20:00:00');

SELECT id,
       placed_utc,
       datetime(placed_utc, '+05:30') AS placed_ist,
       date(placed_utc)               AS utc_day,
       date(placed_utc, '+05:30')     AS ist_day
FROM orders;

Output

┌────┬─────────────────────┬─────────────────────┬────────────┬────────────┐
│ id │     placed_utc      │     placed_ist      │  utc_day   │  ist_day   │
├────┼─────────────────────┼─────────────────────┼────────────┼────────────┤
│ 1  │ 2026-03-31 12:00:00 │ 2026-03-31 17:30:00 │ 2026-03-31 │ 2026-03-31 │
│ 2  │ 2026-03-31 18:29:59 │ 2026-03-31 23:59:59 │ 2026-03-31 │ 2026-03-31 │
│ 3  │ 2026-03-31 18:30:00 │ 2026-04-01 00:00:00 │ 2026-03-31 │ 2026-04-01 │
│ 4  │ 2026-03-31 20:00:00 │ 2026-04-01 01:30:00 │ 2026-03-31 │ 2026-04-01 │
└────┴─────────────────────┴─────────────────────┴────────────┴────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < utc_and_ist.sql

The first two orders fall on the same day in both zones. From 18:30 UTC onwards the IST clock has passed midnight, so orders 3 and 4 belong to the next day in India: grouped by utc_day, all four look like orders of the same day, while grouped by ist_day two of them move. Order 4 also moves into a new month and, in India, a new financial year.

UTC times before 18:30 stay on the same IST day; from 18:30 UTC the IST clock has passed midnight and shows the next day.Stored in UTC:the last evening of MarchSame IST dayNext IST day12:00 UTC = 17:30 IST18:29:59 UTC = 23:59:59 IST18:30 UTC = 00:00 IST20:00 UTC = 01:30 ISTnow in April: a newfinancial yearadd 5 hours 30 minutesIST midnight is 18:30 UTC

Where a UTC evening falls on the IST calendar

Text description of the diagram

The diagram starts from four order times stored in UTC on the evening of the last day of March, and adds 5 hours 30 minutes to each to read the IST clock.

  1. Same IST day: 12:00 UTC is 17:30 IST, and 18:29:59 UTC is 23:59:59 IST, still the last day of March.
  2. Next IST day: 18:30 UTC is 00:00 IST on the first day of April, and 20:00 UTC is 01:30 IST, which is also the first day of a new Indian financial year.

The arrow between the two groups says that midnight in IST is 18:30 UTC. Any report that groups UTC timestamps by their UTC date moves the orders placed between 18:30 and 23:59:59 UTC to the wrong IST day.

SQLite also knows the modifier 'localtime', which converts UTC to the time zone of the computer running the query. It is handy on a laptop and unreliable on a server, whose zone is often UTC, and it gives different answers on different machines, so this lesson’s examples never use it: they name the offset.

Offsets inside the text

A timestamp written with its offset, such as 2026-04-01T01:30:00+05:30 in the RFC 3339 style, names one moment without ambiguity. SQLite reads such offsets and converts the time to UTC:

Text with an offset, with Z and with nothing SQL · offsets_in_text.sql
-- Time-values with an offset or a Z are converted to UTC. Without either, SQLite takes the text to be UTC already.
SELECT datetime('2026-04-01 01:30:00+05:30') AS with_offset,
       datetime('2026-03-31T20:00:00Z')      AS with_z,
       datetime('2026-04-01 01:30:00')       AS without_offset,
       datetime('2026-04-01 01:30:00+0530')  AS offset_without_colon;

Output

┌─────────────────────┬─────────────────────┬─────────────────────┬──────────────────────┐
│     with_offset     │       with_z        │   without_offset    │ offset_without_colon │
├─────────────────────┼─────────────────────┼─────────────────────┼──────────────────────┤
│ 2026-03-31 20:00:00 │ 2026-03-31 20:00:00 │ 2026-04-01 01:30:00 │                      │
└─────────────────────┴─────────────────────┴─────────────────────┴──────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < offsets_in_text.sql

+05:30 and Z (for UTC) are understood, and both strings here name the same moment, 20:00 UTC. Text without an offset is taken to be UTC already, so an IST clock reading stored without its offset is silently 5 hours 30 minutes off. The offset must be written with a colon: +0530 is not accepted and gives NULL.

PostgreSQL reads offsets too, but only for the type timestamp with time zone. Its manual warns that a value of timestamp without time zone silently ignores any zone written in the input, so '2026-04-01 01:30:00+05:30' cast to timestamp keeps 01:30 and drops the offset.

Store UTC, convert at the edges

The safe pattern is to convert local input to UTC once, when it is stored, and to convert back only for display:

An IST entry stored in UTC and shown in IST SQL · store_utc.sql
-- Store UTC, convert at the edges: an order entered at 01:30 IST is stored as 20:00 UTC the evening before.
CREATE TABLE orders (id INTEGER PRIMARY KEY, placed_utc TEXT NOT NULL);
INSERT INTO orders (placed_utc) VALUES (datetime('2026-04-01 01:30:00', '-05:30'));

SELECT id,
       placed_utc,
       datetime(placed_utc, '+05:30') AS shown_in_ist
FROM orders;

Output

┌────┬─────────────────────┬─────────────────────┐
│ id │     placed_utc      │    shown_in_ist     │
├────┼─────────────────────┼─────────────────────┤
│ 1  │ 2026-03-31 20:00:00 │ 2026-04-01 01:30:00 │
└────┴─────────────────────┴─────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < store_utc.sql

The person entered 01:30 IST, the table holds 20:00 UTC on the evening before, and the report shows 01:30 IST again. Every stored row now uses the same clock, so sorting, differences and joins with data from other systems work without asking which zone each row was written in. A column that mixes UTC rows with IST rows cannot be repaired afterwards, because nothing in the text says which is which.

Instants and clock readings in each engine

PostgreSQL and MySQL each have one type for moments and one for clock readings, and they convert in different places:

Timestamps and time zones in PostgreSQL, MySQL and SQLite
Criterion PostgreSQL 18MySQL 8.4SQLite 3.49
A moment timestamptzTIMESTAMPUTC text or Unix seconds
A clock reading timestampDATETIMEText without an offset
Session zone SET TIME ZONESET time_zoneNone
UTC moment to IST AT TIME ZONE 'Asia/Kolkata'CONVERT_TZ(dt, '+00:00', '+05:30')datetime(t, '+05:30')
Latest year 294276TIMESTAMP: 2038, DATETIME: 99999999

In PostgreSQL a timestamptz value is stored as a UTC instant and printed in the session’s time zone: with SET TIME ZONE 'Asia/Kolkata', an instant stored as 12:00 UTC prints as 17:30 with the offset +05:30. AT TIME ZONE works in both directions. Applied to a timestamptz, it returns the clock reading in the named zone, a timestamp; applied to a timestamp, it reads the clock as a time in that zone and returns the instant, a timestamptz. Use full zone names such as 'Asia/Kolkata'. The manual notes that POSIX-style zone strings flip the sign, so a name such as 'UTC+5:30' means a zone behind UTC, not ahead of it.

MySQL converts TIMESTAMP values from the session’s time_zone to UTC when it stores them and back when it reads them, while DATETIME keeps whatever it was given. A connection whose zone differs from the one that wrote the data therefore reads different TIMESTAMP values, and identical DATETIME ones. The time_zone setting accepts offsets such as '+05:30' at once, but names such as 'Asia/Kolkata' only after the time zone tables have been loaded: until then, SET time_zone with a name fails with error 1298, and CONVERT_TZ() with a name returns NULL, as it does for any invalid argument.

Unix time and the year 2038

Unix time counts the seconds since the first moment of 1970 in UTC, so it names a moment with a single integer and no zone at all:

Unix seconds, and the last second of 32-bit Unix time SQL · unix_seconds.sql
-- Unix time counts seconds from 1970-01-01 00:00:00 UTC, so it names a moment without needing a zone.
SELECT unixepoch('2026-03-31 20:00:00')             AS unix_seconds,
       datetime(1774987200, 'unixepoch')             AS as_utc,
       datetime(1774987200, 'unixepoch', '+05:30')   AS as_ist;

-- The largest value of a signed 32-bit integer, and one second later.
SELECT datetime(2147483647, 'unixepoch') AS last_32_bit_second,
       datetime(2147483648, 'unixepoch') AS one_second_later;

Output

┌──────────────┬─────────────────────┬─────────────────────┐
│ unix_seconds │       as_utc        │       as_ist        │
├──────────────┼─────────────────────┼─────────────────────┤
│ 1774987200   │ 2026-03-31 20:00:00 │ 2026-04-01 01:30:00 │
└──────────────┴─────────────────────┴─────────────────────┘
┌─────────────────────┬─────────────────────┐
│ last_32_bit_second  │  one_second_later   │
├─────────────────────┼─────────────────────┤
│ 2038-01-19 03:14:07 │ 2038-01-19 03:14:08 │
└─────────────────────┴─────────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < unix_seconds.sql

unixepoch() turns text into seconds and the 'unixepoch' modifier turns seconds back into text, to which an offset can be added for display. The second query shows a famous limit. 2147483647, the largest signed 32-bit integer, is the last second that 32-bit Unix time can count, early on a January morning of 2038 in UTC. SQLite stores integers in 64 bits and simply goes on, but MySQL’s TIMESTAMP type stops exactly there: its range ends at 03:14:07 UTC on that day, and in strict mode an INSERT of a later moment fails. Expiry dates, loan schedules and certificates already reach past that point, so store far-future moments in DATETIME (in UTC, by your own rule) or as BIGINT seconds.

IST in daily reports

Indian teams usually report by the IST calendar day, the IST month and the financial year that begins in April. Because IST midnight is 18:30 UTC, every count, revenue figure or financial-year total computed from the UTC date moves the last 5 hours 30 minutes of each day, month and year into the wrong period. Convert first, then group.

Time Zone Converter Check a UTC time against IST and other zones before you write the conversion into a query.

Your turn

Exercise · Medium · SQL

Count paid orders per day on the IST calendar

An online shop in India stores order times in UTC. The table orders has the columns id, placed_utc (text such as 2026-03-31 20:00:00, always UTC) and status (paid or cancelled).

The shop's daily report counts orders by the calendar day in India Standard Time (IST), which is UTC plus 5 hours and 30 minutes all year. Write a query that returns one row per IST day with at least one paid order:

  • ist_day: the day in IST, as YYYY-MM-DD text;
  • orders: how many paid orders were placed on that IST day.

Cancelled orders are not counted, and a day with no paid order has no row. The order of the rows does not matter. The sample tests include orders placed between 18:30 and 23:59 UTC, which belong to the next day in IST, and the turn of a year.

Starter code · ist_days.sql

-- One row per IST calendar day with at least one paid order:
-- ist_day (YYYY-MM-DD in IST) and orders (the number of paid orders that day).
SELECT date(placed_utc) AS ist_day,
       count(*)         AS orders
FROM orders
GROUP BY ist_day;
The sample tests · tests.yaml
# Sample tests: each one builds a small orders table, runs your query on it and compares the rows it returns (in any
# order).
tests:
  - name: moves orders from 18:30 UTC onwards to the next IST day
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, placed_utc TEXT NOT NULL, status TEXT NOT NULL);
      INSERT INTO orders VALUES
        (1, '2026-03-31 12:00:00', 'paid'), (2, '2026-03-31 18:29:59', 'paid'), (3, '2026-03-31 18:30:00', 'paid'),
        (4, '2026-03-31 23:59:59', 'paid'), (5, '2026-04-01 06:00:00', 'paid');
    expected:
      columns: [ist_day, orders]
      rows:
        - ['2026-03-31', 2]
        - ['2026-04-01', 3]
  - name: skips cancelled orders and crosses the turn of the year
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, placed_utc TEXT NOT NULL, status TEXT NOT NULL);
      INSERT INTO orders VALUES
        (1, '2026-12-31 19:00:00', 'paid'), (2, '2026-12-31 17:00:00', 'paid'), (3, '2026-12-31 20:00:00', 'cancelled'),
        (4, '2027-01-01 18:45:00', 'paid');
    expected:
      columns: [ist_day, orders]
      rows:
        - ['2026-12-31', 1]
        - ['2027-01-01', 1]
        - ['2027-01-02', 1]
  - name: leaves out a day whose only orders were cancelled
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, placed_utc TEXT NOT NULL, status TEXT NOT NULL);
      INSERT INTO orders VALUES
        (1, '2026-06-10 10:00:00', 'cancelled'), (2, '2026-06-11 02:00:00', 'paid'), (3, '2026-06-10 20:00:00', 'paid');
    expected:
      columns: [ist_day, orders]
      rows:
        - ['2026-06-11', 2]
A hint

date(placed_utc, '+05:30') moves the UTC time to the IST clock before it takes the date, so 18:30 UTC and later fall on the next day. Group by that expression, count the rows, and keep only paid orders with WHERE before the grouping.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 What does this query return in SQLite?

    Read the code, then choose one answer.

    SELECT datetime('2026-03-31 20:00:00', '+05:30') AS ist;
    Show the answer to question 1

    Answer: 01:30 on the next day

    The modifier adds 5 hours 30 minutes, and 20:00 plus 5:30 passes midnight, so the IST clock reads 01:30 on the next day. Subtracting would give 14:30, the UTC time of a clock reading of 20:00 IST.

  2. Question 2 of 6 An order is stored at 18:30:00 UTC. On which day is it in India Standard Time?

    Choose one answer.

    Show the answer to question 2

    Answer: The next day, at 00:00 IST

    IST is UTC+05:30 all year, with no daylight saving time, so 18:30 UTC is exactly midnight in IST: the first moment of the next day.

  3. Question 3 of 6 The text in this query has no offset. How does SQLite read the time?

    Read the code, then choose one answer.

    SELECT datetime('2026-04-01 01:30:00') AS t;
    Show the answer to question 3

    Answer: As a time that is already in UTC

    SQLite's date and time functions work in UTC and treat text without an offset as UTC. That is why an IST clock reading stored without its offset is 5 hours 30 minutes off.

  4. Question 4 of 6 In MySQL 8.4, which type converts values through the session's time_zone when they are stored and read?

    Choose one answer.

    Show the answer to question 4

    Answer: TIMESTAMP

    MySQL stores TIMESTAMP values in UTC and converts them from and to the session's time_zone; DATETIME keeps the value it was given. A session with another time_zone therefore reads different TIMESTAMP values.

  5. Question 5 of 6 In PostgreSQL, what happens to the offset in this cast to timestamp (without time zone)?

    Read the code, then choose one answer.

    SELECT '2026-04-01 01:30:00+05:30'::timestamp AS t;
    Show the answer to question 5

    Answer: It is silently ignored, and the value keeps the clock reading 01:30

    A timestamp without time zone ignores any zone written in its input. Cast to timestamptz instead, which converts the text to a UTC instant using the offset.

  6. Question 6 of 6 In which year does the largest signed 32-bit Unix time, 2147483647 seconds after the start of 1970 in UTC, fall?

    Type a number.

    Show the answer to question 6

    Answer: 2038

    2147483647 seconds is about 68 years, which ends early in 2038 (at 03:14:07 UTC). MySQL's TIMESTAMP type stops at that second, so use DATETIME or BIGINT seconds for later moments.

Interview questions

Fresher · What is the difference between timestamp and timestamptz in PostgreSQL?

timestamp (without time zone) is a clock reading: PostgreSQL stores the date and time exactly as given and ignores any offset written in the input, so it cannot tell whether 09:00 meant IST or UTC. timestamptz (with time zone) is an instant: input with an offset, or interpreted in the session’s time zone, is converted to UTC and stored as UTC, and on output it is shown in the session’s current zone, so the same row prints as 12:00+00 for one session and 17:30+05:30 for another without being changed. The name misleads, because no zone is stored, only the instant. Use timestamptz for events that happened at a moment, such as orders, payments and logins, and plain timestamp or date for local schedules, such as a shop that opens at 09:00 wherever it is.

Key takeaways

  • Store moments in UTC, as timestamptz in PostgreSQL, as UTC text or Unix seconds in SQLite, and convert to a local zone only for input and display.
  • IST is UTC+05:30 all year (Asia/Kolkata), so from 18:30 UTC onwards an event belongs to the next IST day.
  • SQLite shifts with modifiers such as '+05:30', converts text with +05:30 or Z to UTC, treats text without an offset as UTC, and refuses offsets without a colon.
  • PostgreSQL’s timestamp ignores offsets in its input; AT TIME ZONE converts in both directions; POSIX-style zone strings flip the sign.
  • MySQL converts TIMESTAMP through the session time_zone and leaves DATETIME alone; named zones need the time zone tables.
  • MySQL’s TIMESTAMP ends with the last second of signed 32-bit Unix time in the year 2038; use DATETIME or BIGINT seconds for later moments.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.