Open wp-settings.php on any WordPress install and look at line 73. It says date_default_timezone_set( 'UTC' );, and it runs on every single request before a theme or plugin has loaded. PHP’s clock is pinned to UTC for the entire life of that request.
That’s why a theme calling date( 'g:i a' ) prints UTC, not your local time, even though you picked a city in Settings, General years ago. The dropdown isn’t ignored and it isn’t broken. It just doesn’t do the thing almost everyone assumes it does, which is set PHP’s timezone. It sets a WordPress option, and only the functions that bother to read that option will agree with you.
The gap between those two clocks is where a specific family of bugs lives: a footer copyright year that flips over hours early, a scheduled post that publishes at the wrong hour, an “updated 5 hours ago” label on a post that was saved a minute ago. Here’s what core actually stores, which functions respect it, and how to check your own install.
WordPress Runs Every Request in UTC
The line is short and it’s not conditional:
// wp-settings.php
date_default_timezone_set( 'UTC' );
There’s no filter on it and no constant to override it. By the time your functions.php runs, PHP believes it lives in UTC, and every native date function inherits that belief. date(), mktime(), strtotime(), date('Y') for a copyright line, and anything built on them are all operating in UTC.
What that actually breaks
If your site is set to a zone behind UTC, the practical effect is that PHP’s idea of “now” runs ahead of your wall clock. In New York that gap is five hours in winter and four in summer, because the offset moves with daylight saving. Take the winter figure: a template printing date( 'Y' ) rolls over to the new year at 7pm local time on December 31st, five hours before the year actually changes for anyone reading. A “posted today” check written with date( 'Y-m-d' ) has the same shape, quietly counting tomorrow’s posts as today’s from early evening onward.
These are quiet failures. Nothing errors, nothing gets logged, and the output looks plausible. You just get a number that’s wrong for part of every day, which is exactly the kind of defect that survives testing: whoever checks the page sees a believable date and moves on.
Why core does it on purpose
Pinning PHP to UTC makes a WordPress install portable. Two servers in different regions run the same code and get the same answer, and the database doesn’t end up holding timestamps whose meaning depends on the host’s system configuration. Core leans into this by storing post dates twice, in post_date and post_date_gmt, so there’s always an unambiguous UTC value alongside the local one.
It’s a defensible design. The cost is that “get the current time” becomes a question with two different correct answers, and the function names don’t make it obvious which one you’re asking for.
Timezone bugs are time-dependent, which means they hide from you. A date comparison that’s off by four hours works perfectly all morning and fails after 8pm. Whoever tests the fix at 10am will confirm the bug is gone, and it will be back that evening. The only reliable way to catch these is to read which function the code called, not to watch the output.
One Dropdown, Two Options
The Timezone control in Settings, General writes to two separate options, and understanding which one is populated explains most surprising behavior.
timezone_string holds a named identifier like America/New_York. gmt_offset holds a plain number like -5. The dropdown offers both kinds of choice: real city names grouped by continent, plus a set of manual UTC+ and UTC- entries at the bottom.
The fallback ladder in wp_timezone_string()
Everything modern in core resolves the site timezone through one function, and its logic is short enough to read in full:
function wp_timezone_string() {
$timezone_string = get_option( 'timezone_string' );
if ( $timezone_string ) {
return $timezone_string;
}
$offset = (float) get_option( 'gmt_offset' );
// ... builds a +HH:MM string from the offset
}
The named string wins whenever it’s set. Only when it’s empty does core fall back to building an offset string like -05:00 out of the numeric option. wp_timezone() then wraps that in a DateTimeZone object, and that object is what the good date functions use.
This is also the reason a named zone is the better pick. A DateTimeZone built from America/New_York knows about daylight saving and returns a different offset in January than in July. One built from a fixed -05:00 doesn’t, so it’s wrong for the roughly eight months a year New York spends on daylight time. On the day this was written, a live check on New York returned an offset of -4 hours, not -5, which is precisely the difference a hardcoded number can’t express.
The gmt_offset that fixes itself at read time
Here’s where it gets strange. The Settings, General form has no input for gmt_offset at all. You can confirm it:
grep -n 'name="gmt_offset"' wp-admin/options-general.php
# no matches
But gmt_offset is on the allowed-options list for that page, so the save handler tries to update it from whatever was submitted. The mapping branch in options.php that converts a selection into a number only fires for the manual UTC+ and UTC- entries. Pick a named city and that branch is skipped, no offset field was ever submitted, and the value reaching update_option() is null. The sanitizer for this option is unforgiving about non-numeric input:
case 'gmt_offset':
if ( is_numeric( $value ) ) {
$value = preg_replace( '/[^0-9:.-]/', '', $value );
} else {
$value = '';
}
break;
Null isn’t numeric, so an empty string goes into the database. Verified on a current install, sanitize_option( 'gmt_offset', null ) returns ''.
Which sounds like a bug waiting to happen, except core anticipated it. default-filters.php registers a short-circuit filter on that exact option:
add_filter( 'pre_option_gmt_offset', 'wp_timezone_override_offset' );
get_option() applies pre_option_{$option} filters and returns immediately if one gives back anything other than false, before it ever reads the stored row. And wp_timezone_override_offset() returns false only when timezone_string is empty; otherwise it opens the named zone and computes the current offset with timezone_offset_get().
So on a named-zone site, gmt_offset behaves less like a stored setting and more like a live reading. On a site set to America/New_York in September, get_option( 'gmt_offset' ) returns -4, the daylight-saving-correct value, while the database row behind it is empty. Fixed-offset sites are unaffected, because the filter declines and the stored number is used.
That leaves a narrower failure mode than you’d expect, but a real one: anything that bypasses get_option() misses the filter. A direct $wpdb query against the options table, or a read taken before default-filters.php has loaded, sees the raw empty string. Cast to float that’s 0.0, and the code concludes your site runs on UTC.
The Date Functions That Respect Your Setting
There are more date helpers in WordPress than anyone needs, largely for backward compatibility. Most work only needs a small handful of them, plus one to actively avoid.
wp_date() is the one you want for display
Added in WordPress 5.3, wp_date( $format, $timestamp = null, $timezone = null ) takes a true Unix timestamp, defaults the timezone to wp_timezone(), and formats the result. It also runs month and weekday names through the site locale, so a translated site gets translated names instead of English ones. Anywhere you’d reach for date() in a template, reach for this instead.
date_i18n() still exists and still works. Since 5.3 it’s a wrapper around wp_date(), carrying some awkward legacy timestamp handling for older code, so there’s no reason to start new work with it.
current_time() and its timestamp that isn’t one
This function’s two modes come from genuinely different sources, which is the trap. Ask for mysql or any date format string and you get a correct local time built from wp_timezone(). Ask for timestamp and you get this:
if ( 'timestamp' === $type || 'U' === $type ) {
return $gmt ? time() : time() + (int) ( (float) get_option( 'gmt_offset' ) * HOUR_IN_SECONDS );
}
Two problems stack up here. The first is by design: adding an offset to a Unix timestamp produces a number that is no longer a valid Unix timestamp, just a value that happens to format correctly if you later treat it as UTC. Core’s own inline comment on the line above says “Don’t use non-GMT timestamp, unless you know the difference and really need to.”
The second is what that value is useful for. Because the offset is added on, the result formats correctly only if you then treat it as UTC. Run it on a site four hours behind and the number comes back exactly 14,400 seconds below time(), so gmdate() renders it as the right local wall clock while any arithmetic against a genuine timestamp is off by your full offset. Two calls into the same function, mysql and timestamp, hand you values that need opposite handling, and nothing in the names tells you that.
current_datetime() and get_post_datetime() for anything you need to compute with
When you need to do arithmetic rather than print a string, use the object-based helpers. current_datetime() is a one-liner returning new DateTimeImmutable( 'now', wp_timezone() ), which gives you a timezone-aware object with the correct offset baked in. For posts, get_post_datetime( $post, 'date', 'local' ) and the gmt variant read the two stored columns and hand back DateTimeImmutable objects.
Comparing two DateTimeImmutable objects is safe regardless of what zone each one carries, because PHP compares the underlying instants. That’s the whole reason to prefer objects over integers here: the timezone travels with the value instead of living in a separate variable you have to remember about.
Reach for these, in this order:
wp_date( $format, $ts ) — formatting a date for a human to read.
current_datetime() — a timezone-aware object for comparisons and math.
get_post_datetime( $post ) — a post’s own date, as an object.
time() — a true UTC Unix timestamp to store or pass around.
get_gmt_from_date( $str ) — parse a local date string into UTC.
Scheduled Posts: Telling a Timezone Bug From a Cron Bug
Post scheduling is the most visible place this machinery shows up, because a mistake produces something everyone notices: a post going live at the wrong hour. It’s also the place where the two clocks line up correctly, which makes it a useful thing to be able to rule out.
When you schedule a post, core hooks the transition into future status and registers a single cron event at a UTC instant:
function _future_post_hook( $deprecated, $post ) {
wp_clear_scheduled_hook( 'publish_future_post', array( $post->ID ) );
wp_schedule_single_event( strtotime( get_gmt_from_date( $post->post_date ) . ' GMT' ), 'publish_future_post', array( $post->ID ) );
}
Read the inner call outward. $post->post_date is the local wall-clock time you typed. get_gmt_from_date() parses that string using wp_timezone() and converts it to UTC. Only then does it become a scheduled timestamp. Every conversion here runs through the site timezone rather than PHP’s, which is why scheduling works correctly despite the UTC pinning.
There’s a second gate on the other end. When the event fires, check_and_publish_future_post() re-derives the time from post_date_gmt and compares it to now. If the event somehow arrived early, it doesn’t publish; it clears itself, reschedules for the correct instant, and returns. Core’s comment on that branch is blunt about why it exists: // Uh oh, someone jumped the gun!
The practical consequence: if scheduled posts land at a consistently wrong hour, suspect the site timezone setting. If they land late by a variable amount, that’s not a timezone issue at all, it’s WP-Cron waiting for traffic.
Audit Your Own Install in Five Minutes
Three checks tell you almost everything. First, read the two options and see which one is actually driving your site:
wp option get timezone_string
wp option get gmt_offset
A named zone on the first line is the healthy state for a modern install. An empty first line with a number under it means you’re on a fixed offset, and you should switch to a named zone so daylight saving gets handled for you. Note that the second command reads through get_option(), so on a named-zone site it prints the offset core computed from your zone rather than whatever sits in the database row, and that printed number legitimately changes when daylight saving starts.
Next, confirm what core resolves that to, and prove the UTC pinning to yourself:
wp eval 'echo wp_timezone_string() . " | " . date_default_timezone_get();'
On a correctly configured site the first value is your zone and the second is UTC. Those disagreeing is not a bug, it’s the entire design.
Then find the code that’s getting it wrong. Bare PHP date calls in a theme are the usual culprits, and a grep finds them faster than reading:
grep -rn "date(\|mktime(\|strtotime(" wp-content/themes/your-theme/
Not every hit is a defect. strtotime() on a string that already carries an explicit zone is fine, and so is date() when the output is genuinely meant to be UTC. What you’re hunting for is anything formatting a date for a visitor to read, or comparing dates to decide what “today” means. Those want wp_date() and current_datetime().
If you’re writing timestamps into post meta or a custom table, store the UTC value from time() and convert only at display time with wp_date(). A stored local timestamp becomes unreadable the moment someone changes the site timezone, because you can no longer tell which offset it was recorded under. Core already follows this rule with post_date_gmt, which is why a timezone change never corrupts existing posts.
Frequently Asked Questions
If your dates look wrong and you’d rather have someone read the code than guess at the setting, that’s the kind of thing we do.
Built by amplifi.studio — see also Why Your Scheduled WordPress Posts Keep Missing Their Publish Time.