# WordPress Pins PHP to UTC on Every Request, and Your Timezone Dropdown Doesn't Change It

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.

**WHY IT MATTERS**

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.

**QUICK REFERENCE**

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()`.

**STORE UTC, DISPLAY LOCAL**

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

Why does date() return UTC inside a WordPress theme?Because WordPress calls date\_default\_timezone\_set with UTC in wp-settings.php on every request, before themes and plugins load. PHP date and time functions inherit that setting, so date, mktime, and strtotime all operate in UTC regardless of what is chosen in Settings, General. Use wp\_date for anything a visitor will read.

Does changing the timezone setting rewrite dates on existing posts?No. Each post stores both a local and a UTC timestamp when it is saved, and changing the site timezone later does not rewrite either column. Code that reads the stored local value keeps returning the same wall-clock time as before, while anything recalculating from the UTC value will render under the new zone.

Should I pick a named city or a UTC offset in the dropdown?Pick the named city. A named zone such as America/New\_York carries daylight saving transitions, so the offset shifts automatically twice a year. A fixed UTC offset never shifts, which makes it wrong for part of the year in any region that observes daylight saving time.

Is current\_time with the timestamp type safe to use?It returns a Unix timestamp with the site offset already added, which is not a valid Unix timestamp. Core's own inline comment in that function warns against using it unless you understand the difference. It formats correctly only if you then treat it as UTC, and any arithmetic against a real timestamp is off by your offset. Prefer current\_datetime for a timezone aware object, or plain time for a true UTC timestamp.

What does an empty gmt\_offset option mean?The stored row can genuinely be empty on a site using a named timezone, because the Settings, General form submits no offset field. Reads are covered anyway: core filters pre\_option\_gmt\_offset and computes the offset from the named zone, so get\_option returns a live, daylight-saving-correct number. Only code that bypasses get\_option and queries the database directly sees the empty string and treats the site as UTC.

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](https://amplifi.studio) — see also [Why Your Scheduled WordPress Posts Keep Missing Their Publish Time](https://amplifi.studio/wordpress-wp-cron-explained-guide/).