Cloud & Migration

SYSDATE Is Hours Off After the Move to the Cloud. Nothing Errors.

· 10 min read

The migration went well. The database moved from the server room to the cloud over the weekend, the row counts matched, the application connected, and nobody saw an error. On Monday afternoon someone asks why the “today’s orders” report has stopped showing the evening’s orders. On Tuesday the nightly export picks up rows from the wrong day. The audit log and the application log disagree by five hours.

Nothing is broken, and that’s why it takes days to find. Every SYSDATE in the database now returns UTC, and every column with DEFAULT SYSDATE is filling with UTC times, in the same column as years of local times.

SYSDATE doesn’t come from the database. It comes from the operating system’s clock and time zone, as the database server process sees them. The on-prem server ran in local time. The new host, like most cloud servers and nearly every container image, runs in UTC. This post shows exactly what moves, what doesn’t, the fixes that look right and change nothing, and the two that work. All of it is reproduced in a lab.

What moves, and what doesn’t

Oracle has several “now” functions, and they don’t all read the same clock:

functionwhere its time zone comes from
SYSDATE, SYSTIMESTAMPthe server process’s OS time zone (TZ)
CURRENT_DATE, CURRENT_TIMESTAMP, LOCALTIMESTAMPthe session time zone, which the client sets
DBTIMEZONEa database setting, used for TIMESTAMP WITH LOCAL TIME ZONE columns

The first row is the one that matters, because it’s what applications use: DEFAULT SYSDATE on a column, WHERE order_date >= TRUNC(SYSDATE) in a report, SYSDATE - 1 in a purge job. When the database moves to a host in another time zone, all of it moves with the host.

An order placed at 8pm in Chicago is stored as 1am the next day (2am in winter). Every report that groups by TRUNC(order_date) puts the evening’s orders on tomorrow.

Prove it

The lab runs Oracle Database Free in a container and “moves” it the only way that matters for SYSDATE: it starts the instance with TZ=America/Chicago (the on-prem server), then restarts the same instance in UTC (the cloud host). An application schema has an ORDERS table with ORDER_DATE DATE DEFAULT SYSDATE. Before and after the move, it checks what the application’s session and a scheduler job see, and places one order:

                                      on-prem (Chicago)    moved (UTC)
app session, SYSDATE                  07:28                12:28
SYSDATE offset from UTC               -5 h                 0 h
scheduler job, SYSDATE offset         -5 h                 0 h

order 1  on-prem         2026-10-31 07:28
order 2  in the cloud    2026-10-31 12:28     <- placed seconds later

Two orders placed seconds apart are stored 5 hours apart (6 in winter, when Chicago is UTC-6). The second one is in UTC, in a column full of Chicago times, and nothing in the database records which is which.

Don’t take my word for it — run it. The sysdate-timezone lab asserts every lab result in this post: the jump to UTC for the app and for a scheduler job, the 5-hour gap, each fix below that doesn’t work, the two that do, and the DST trap. Chicago’s offset is read from the database at run time, so it holds all year. If any of it stops reproducing, the run fails. Proven on every CI push.

Four fixes that change nothing

Setting the database time zone. The first thing most people try:

ALTER DATABASE SET TIME_ZONE = 'America/Chicago';

In the lab, after reopening the PDB, DBTIMEZONE is America/Chicago and the application’s SYSDATE is still UTC. DBTIMEZONE only applies to TIMESTAMP WITH LOCAL TIME ZONE data. SYSDATE ignores it (unless you turn on the setting in fix 2 below).

Checking from a DBA shell. A DBA logs in to the new host, sets TZ=America/Chicago in their shell, runs sqlplus / as sysdba, and sees Chicago time. Fixed? In the lab, at the same moment, the application’s session was still on UTC. A local / as sysdba connection’s server process is started by your sqlplus, so it takes the TZ of your shell. The application’s connections come in over the listener and don’t. Always check over the application’s connection: SELECT SYSDATE, SYSTIMESTAMP FROM dual through the same service the app uses.

Setting TZ on the listener. Many runbooks say the listener’s environment is what server processes inherit. On 26ai in the lab, restarting only the listener with TZ=America/Chicago changed nothing; the application’s server processes took the instance’s TZ. Older releases may behave differently, and setting it on both is harmless, but the listener alone didn’t do it here.

Switching the code to CURRENT_DATE. CURRENT_DATE uses the session’s time zone, which the client sets. With an app server in UTC (and in the cloud, the app servers usually are), it’s UTC too. In the lab it returned Chicago time only when the client set ORA_SDTZ=America/Chicago. That works, but it means every client, batch job and tool has to set it, and the first one that doesn’t writes UTC again.

The scheduler is fine. Except for one thing.

I expected the 2am jobs to start running at 9pm. They didn’t. DBMS_SCHEDULER stores a time zone with each job (in START_DATE, or the scheduler’s default_timezone, which the database keeps), so after the move the lab’s 2am job was still due at 2am Chicago:

NIGHTLY_REGION   next run due 02:00 Chicago (after the move)

The trap is a job whose START_DATE was written as a fixed offset. SYSTIMESTAMP returns one (-05:00 in summer), and so does any start_date => SYSTIMESTAMP. A fixed offset doesn’t know about daylight saving time. In the lab, a 2am job stored with -05:00:

                           next run, Chicago time
                           summer     after the November DST change
start_date ... -05:00      02:00      01:00
start_date ... Chicago     02:00      02:00

It runs an hour early from November to March, every year, whether or not you move the database. Find them:

SELECT owner, job_name, TO_CHAR(start_date, 'TZR') AS tz
FROM   dba_scheduler_jobs
WHERE  REGEXP_LIKE(TO_CHAR(start_date, 'TZR'), '^[+-]');

and recreate them with a region name, TO_TIMESTAMP_TZ('... America/Chicago', '... TZR').

Fix 1: give the instance its time zone back

SYSDATE follows the instance’s TZ, so set TZ in the environment that starts the instance, and restart it. In the lab, the instance restarted with TZ=America/Chicago (the listener left in UTC) put the application’s sessions and the scheduler’s jobs back on Chicago time.

Where that environment lives depends on how the database starts:

  • A start script or systemd unit: Environment=TZ=America/Chicago in the unit, or export TZ=... in the script. Not the DBA’s .bash_profile; the database has to get it when it starts without you.
  • Grid Infrastructure / Oracle Restart: srvctl setenv database -db <db> -env "TZ=America/Chicago", then restart it with srvctl.
  • A container: TZ in the container’s environment (environment: in Compose, env in Kubernetes).
  • A managed database service: you can’t set the OS environment there. Check the provider’s time zone setting.

The last three aren’t tested in the lab; the lab sets TZ the way the first does, in the environment of the process that runs STARTUP. It needs an instance restart. It also has a cost: the database server is now the one server in your cloud that doesn’t run in UTC, and everyone who works on it has to know that.

Fix 2: let the database answer in its own time zone (26ai)

Oracle AI Database 26ai (the release that was 23ai, now version 23.26) added TIME_AT_DBTIMEZONE; Oracle’s reference says it’s available “starting with Oracle AI Database 26ai”. With it set to USER_SQL, SYSDATE and SYSTIMESTAMP in SQL return the time in DBTIMEZONE, whatever the OS runs in. The host stays in UTC:

ALTER DATABASE SET TIME_ZONE = 'America/Chicago';
ALTER SYSTEM SET time_at_dbtimezone = USER_SQL SCOPE = SPFILE;
-- then reopen the PDB (or restart the database)

In the lab (run in the PDB, then a PDB close and open), the application’s SYSDATE was Chicago time again with the OS still in UTC, so was a scheduler job’s, and a new order’s DEFAULT SYSDATE matched the on-prem one:

app sees: SYSDATE=07:28  SYSTIMESTAMP=07:28 AMERICA/CHICAGO   (OS clock: 12:28 UTC)

Two things to know before you try it:

  • ALTER DATABASE SET TIME_ZONE fails with ORA-30079 while any TIMESTAMP WITH LOCAL TIME ZONE column holds data. In the lab one row in such a column was enough; an empty column didn’t block it. Check first:

    SELECT owner, table_name, column_name FROM dba_tab_columns
    WHERE  data_type LIKE 'TIMESTAMP(%) WITH LOCAL TIME ZONE' AND owner NOT IN ('SYS', 'SYSTEM');
  • It’s 26ai. On 19c, or an older 23ai Release Update without the parameter, you have fix 1. (You may also see SYSDATE_AT_DBTIMEZONE. Oracle documents it for Autonomous Database Serverless only; on the lab’s 26ai Free it can’t be changed, ORA-60490.)

The parameter is set per PDB and only takes effect at the next open. Oracle also documents a DATABASE value, which applies DBTIMEZONE to all time-dependent operations, not just SYSDATE and SYSTIMESTAMP in SQL. The lab tests USER_SQL only.

Which fix fits: the release decides whether the database can keep its own time zone; otherwise the instance has to start in it. Either way, the rows written before the fix need correcting.

The rows written in between

Neither fix touches data. In the lab, the order placed between the move and the fix was still 5 hours off after both fixes. Every DEFAULT SYSDATE column, and everything the application wrote with SYSDATE, is UTC for that window.

Find the window by key, not by time: the first and last order ID (or audit row, or batch run) after the move and before the fix. Then convert those rows from UTC to local time, letting the database handle daylight saving:

UPDATE orders
SET    order_date = CAST(FROM_TZ(CAST(order_date AS TIMESTAMP), 'UTC')
                         AT TIME ZONE 'America/Chicago' AS DATE)
WHERE  id BETWEEN :first_id_after_move AND :last_id_before_fix;

(For a UTC value of 2026-11-03 01:30 that gives 2026-11-02 19:30, six hours back and on the previous day.) Do it once, for each table, and keep the list of what you changed.

Before the next move

  1. Record what the app sees on the old server: SELECT SYSDATE, SYSTIMESTAMP, SESSIONTIMEZONE FROM dual over the application’s own service. That’s the answer the new server has to give.
  2. Decide the fix before cutover: TZ for the instance, or TIME_AT_DBTIMEZONE on 26ai. Put it in the build, not in a ticket for after.
  3. Check over the app’s connection after the move, not from a DBA shell on the host.
  4. Fix the scheduler’s fixed offsets while you’re there; that one bites every November regardless.

Longer term, the cleanest answer is the one cloud teams already use: store times in UTC, or in TIMESTAMP WITH TIME ZONE, and convert for display. Then the server’s time zone stops mattering. That’s an application change, though, and it won’t be done by Monday.

What teams get wrong

  • Testing the move by “does it connect and do the counts match.” Neither notices a 5-hour shift. Compare a SYSDATE taken over the application’s connection on both sides.
  • Trusting ALTER DATABASE SET TIME_ZONE. It’s the setting with “time zone” in the name, and on its own it does nothing for SYSDATE.
  • Checking the fix as SYSDBA from the host. Your shell’s TZ is not the application’s.
  • Fixing the clock and forgetting the data. Everything written between the move and the fix is still wrong, and the first report run over that range will say so.
  • Moving the problem to every client by switching to CURRENT_DATE and relying on each one to set its session time zone.

Frequently asked questions

Why is Oracle SYSDATE showing the wrong time after a cloud migration?

SYSDATE and SYSTIMESTAMP return the operating system's time in the time zone (TZ) of the database server process, not a database setting. On-prem servers usually run in local time, while cloud hosts and container images usually run in UTC, so after the move SYSDATE, and every column with DEFAULT SYSDATE, returns UTC. In the companion lab, two orders placed seconds apart before and after the move were stored 5 hours apart.

Does ALTER DATABASE SET TIME_ZONE change SYSDATE?

No. DBTIMEZONE is used for TIMESTAMP WITH LOCAL TIME ZONE data. In the lab, after setting it to America/Chicago and reopening the PDB, SYSDATE was still UTC. The exception is Oracle AI Database 26ai with TIME_AT_DBTIMEZONE set to USER_SQL, which makes SYSDATE and SYSTIMESTAMP in SQL return the time in DBTIMEZONE.

What is the difference between SYSDATE and CURRENT_DATE in Oracle?

SYSDATE uses the database server process's operating system time zone. CURRENT_DATE uses the session time zone, which the client sets (for example with the ORA_SDTZ environment variable or ALTER SESSION SET TIME_ZONE). With a client in UTC, CURRENT_DATE is UTC too; in the lab it returned local time only when the client set ORA_SDTZ.

How do I make SYSDATE return local time on a UTC server?

Either set TZ (for example TZ=America/Chicago) in the environment that starts the database instance and restart it, or, on Oracle AI Database 26ai, set DBTIMEZONE to the local time zone and TIME_AT_DBTIMEZONE to USER_SQL, then reopen the PDB. Changing DBTIMEZONE fails with ORA-30079 while any TIMESTAMP WITH LOCAL TIME ZONE column holds data.

Do Oracle scheduler jobs run at the wrong time after moving to a UTC server?

Not if they were created with a time zone region: DBMS_SCHEDULER stores the time zone with the job, and in the lab a 2am America/Chicago job was still due at 2am Chicago after the move. Jobs whose START_DATE has a fixed offset such as -05:00 (what SYSTIMESTAMP returns) don't follow daylight saving time, and in the lab such a job's next run after the November DST change evaluated to 1am instead of 2am.

How do I fix rows that were stored in UTC after the migration?

Identify the rows written between the move and the fix, preferably by key range, and convert them once with CAST(FROM_TZ(CAST(col AS TIMESTAMP), 'UTC') AT TIME ZONE 'America/Chicago' AS DATE), which also handles daylight saving time. Neither fix changes existing data.

SYSDATE being hours off isn’t a database bug. It’s the database telling you what the new server’s clock says, and the new server runs in UTC. Decide before cutover whether the instance keeps its old time zone or the database answers in its own, check it over the application’s connection, and correct the rows from the gap. It belongs on the cutover checklist with the other things that pass every count and still break the morning after, like sequences after cutover and character-set conversion, and with choosing the migration method in the first place. Then prove it the way that ends the argument — with the sysdate-timezone lab, where the same instance stores two orders seconds apart five hours apart, and the two fixes put them back.

Have a question or some feedback?

I write here in a personal capacity and enjoy comparing notes with other Oracle folks. Say hello.

Get in touch