← Back to Blog

A Cron Expression That Ran at Midnight UTC Instead of Midnight IST

Cron expressions don't carry a timezone. The scheduler interpreting them does — and if you don't tell it which one, it defaults to UTC, which is 5.5 hours off from IST every single day.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work β€” not documentation summaries.
Published 19 Jul 2026· Last reviewed Jul 2026· 6 min read · About the author →
Senior .NET engineer working across ASP.NET Core APIs, EF Core, and Azure deployments.
Key takeaways
  • A cron expression like 0 0 * * * has no timezone of its own — the scheduler evaluating it decides what 'midnight' means
  • Hangfire's RecurringJob.AddOrUpdate defaults to UTC unless you explicitly pass a TimeZoneInfo argument
  • IST (India Standard Time) is a fixed UTC+5:30 offset with no daylight saving — the gap between UTC midnight and IST midnight is constant, every day, all year
  • TimeZoneInfo.FindSystemTimeZoneById accepts both Windows IDs (India Standard Time) and IANA IDs (Asia/Kolkata) since .NET 6's cross-platform timezone conversion
  • The fix is one explicit TimeZoneInfo argument — the bug is that it was never passed at all, not that cron syntax is broken
Table of Contents
πŸ“… Finished and scheduled β€” not yet linked from the blog index or sitemap. Publishing around 19 Jul 2026.

The incident

Here's a common way this bites a team: a daily settlement or reporting job is scheduled with a plain cron expression intended to run at midnight IST, using Hangfire's RecurringJob.AddOrUpdate without an explicit timezone argument. The job runs every day, completes without errors, and produces output, so nothing looks broken. What actually happens is the job fires at midnight UTC, which is 5:30 AM IST, and the delay only becomes visible when someone downstream notices the report consistently lands hours later than expected, long after the schedule was first set up.

Why a cron expression alone doesn't pin down a time

A cron expression like 0 0 * * * ("at minute 0 of hour 0, every day") describes a repeating point in a 24-hour cycle β€” nothing in the syntax itself says which timezone that cycle is anchored to. The scheduler evaluating the expression supplies that context. If nothing tells it otherwise, most .NET scheduling libraries evaluate cron expressions against UTC, because the underlying clock (DateTime.UtcNow in the scheduler's polling loop) is UTC by default in most hosting environments β€” Azure App Service, containers, and most Linux VMs all run their system clock in UTC regardless of where their users are.

The specific gap: IST is UTC+5:30, every day, no exceptions

India Standard Time is a single fixed offset, UTC+5:30, with no daylight saving transitions β€” unlike US or European timezones, the gap between UTC and IST never changes across the year. That makes the bug completely predictable once you know it's there: a job scheduled for midnight UTC always fires at exactly 5:30 AM IST, and a job intended for midnight IST needs to be scheduled for 18:30 UTC the previous day to actually land at 00:00 IST.

// The cron expression itself never changes β€” only the timezone it's evaluated against does.
// "0 0 * * *" evaluated against UTC   -> fires at 00:00 UTC  = 05:30 IST
// "0 0 * * *" evaluated against IST   -> fires at 00:00 IST  = 18:30 UTC (previous day)

Where the timezone actually gets decided in code

In Hangfire, RecurringJob.AddOrUpdate has an overload that accepts a TimeZoneInfo argument β€” omit it, and Hangfire evaluates the cron expression in UTC:

// Missing timezone β€” Hangfire assumes UTC. This fires at 05:30 IST, not midnight IST.
RecurringJob.AddOrUpdate(
    "daily-settlement-report",
    () => GenerateSettlementReport(),
    Cron.Daily); // Cron.Daily() == "0 0 * * *", evaluated in UTC by default

// Fix β€” explicit TimeZoneInfo makes the intended local time unambiguous.
RecurringJob.AddOrUpdate(
    "daily-settlement-report",
    () => GenerateSettlementReport(),
    Cron.Daily,
    new RecurringJobOptions
    {
        TimeZone = TimeZoneInfo.FindSystemTimeZoneById("India Standard Time")
    });

// TimeZoneInfo.FindSystemTimeZoneById accepts both the Windows ID ("India Standard Time")
// and, since .NET 6's cross-platform timezone data conversion, the IANA ID ("Asia/Kolkata") β€”
// either works on Windows, Linux, or macOS hosts.

Quartz.NET has the equivalent concept via CronScheduleBuilder.CronSchedule(cronExpression).InTimeZone(timeZoneInfo), and Azure Functions timer triggers read timezone from the WEBSITE_TIME_ZONE app setting rather than a code-level parameter β€” three different libraries, the same underlying gap: cron syntax is timezone-blind, and something else has to supply the timezone explicitly.

Why this is easy to ship without noticing

A job that's 5.5 hours off doesn't look broken in any obvious way in a staging environment where nobody is precisely checking wall-clock time against IST business hours β€” it runs every day, completes successfully, produces output, and logs no errors. The only signal is a business-side observation like "the report always seems to land later than expected," which is easy to dismiss as normal variability rather than trace back to a missing TimeZoneInfo argument.

What to check

Grep your codebase for every RecurringJob.AddOrUpdate, CronScheduleBuilder, or Azure Functions [TimerTrigger] call and confirm each one either explicitly specifies a timezone, or is deliberately intended to run in UTC. Use the Cron Expression Generator to double check what a given expression actually means before it goes anywhere near a scheduler. This is a distinct failure mode from the Azure timer-trigger UTC mismatch covered here β€” that post is about the hour being wrong; this one is specifically about a fixed 5.5-hour offset with no daylight-saving complications to reason about, which makes it more predictable but no less silent once it's shipped.

Try the free tool

Cron Expression Generator

Build and validate cron expressions with a plain-English explanation of exactly when they'll fire.

Open Cron Expression Generator