Skip to main content
SEMastery
Data Accessintermediate

Scheduling Jobs with Quartz.NET and Database Persistence using EF Core Migrations

Learn Quartz.NET job scheduling in .NET 10 with database persistence. Set up AdoJobStore, create QRTZ tables via EF Core migrations, and survive restarts.

11 min readUpdated November 13, 2025

A school bell that never forgets

Think of the bell in an Indian school. It rings at fixed times: 8:00 for assembly, 10:30 for the short break, 1:00 for lunch. A peon (the bell person) keeps a printed timetable and follows it every single day. The bell rings on time, whether or not the peon remembers each slot by heart, because the timetable is written down on paper.

Now imagine the peon kept the whole timetable only in his head. One morning he falls sick and a new person comes in. The new person has no idea when to ring the bell. The whole school day falls apart.

This is exactly the problem with background jobs in software. A job scheduler is the peon, and your jobs are the bell rings. Quartz.NET is a popular scheduler for .NET. By default it keeps the timetable in memory, like the peon who memorised everything. If your app restarts, the timetable is gone. To fix this, we write the timetable into a database so it survives restarts, crashes, and even moving to a new server. And to create those database tables cleanly, we use EF Core migrations, the same tool you already use for the rest of your data.

By the end of this article you will be able to schedule a job, store it in SQL Server, and create the storage tables with a migration.

What you will build

The plan

Job
Schedule
Persist
Migrate
Survive

Steps

1

Job

Write an IJob

2

Schedule

Trigger with cron

3

Persist

Use AdoJobStore

4

Migrate

Create QRTZ tables

5

Survive

Restart-proof

From an in-memory toy to a durable, restart-proof scheduler.

How Quartz.NET thinks

Quartz has three small ideas. Once you know them, everything else clicks.

  • A Job is the work to do. It is a C# class that implements IJob.
  • A Trigger is the when. It says "run this job every 5 minutes" or "run at 2 AM daily".
  • The Scheduler is the engine. It watches the clock, checks triggers, and fires jobs.

Here is the relationship as a picture.

A scheduler reads triggers and fires the matching jobs.

A job and a trigger are joined by a JobKey and a TriggerKey, which are just names. You can have one job with many triggers (run the report at 9 AM and again at 5 PM), or many jobs each with their own trigger.

Step 1: Install the packages

We are using .NET 10 (the current LTS) and SQL Server. Create a new project and add the Quartz packages.

// In your terminal, inside the project folder
// dotnet add package Quartz
// dotnet add package Quartz.Extensions.Hosting
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
// dotnet add package Microsoft.EntityFrameworkCore.Design
 
// Quartz                       -> the scheduler core
// Quartz.Extensions.Hosting    -> runs Quartz as a hosted service
// Microsoft.EntityFrameworkCore.SqlServer -> EF Core for SQL Server
// Microsoft.EntityFrameworkCore.Design    -> the migration tooling

Quartz.Extensions.Hosting also brings in the Microsoft DI integration, so your jobs can use constructor injection just like any other service.

Step 2: Write a job

A job is one class. Quartz calls its Execute method when a trigger fires. Notice we inject a logger through the constructor, which works because Quartz uses the built-in DI container.

using Quartz;
 
[DisallowConcurrentExecution] // never run two copies of this job at once
public sealed class SendRemindersJob : IJob
{
    private readonly ILogger<SendRemindersJob> _logger;
 
    public SendRemindersJob(ILogger<SendRemindersJob> logger)
    {
        _logger = logger;
    }
 
    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Sending reminders at {Time}", DateTimeOffset.Now);
 
        // Your real work goes here: query the DB, call an API, send email.
        await Task.Delay(200); // pretend work
    }
}

The [DisallowConcurrentExecution] attribute is a small but important safety belt. If a job takes longer than its schedule interval, Quartz will not start a second copy on top of the first one.

Step 3: Schedule the job (in memory first)

Before we touch the database, let us run it the simple way. This stores the schedule in RAM. It is perfect for a first test.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("send-reminders");
 
    q.AddJob<SendRemindersJob>(opts => opts.WithIdentity(jobKey));
 
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("send-reminders-trigger")
        .WithCronSchedule("0 0/5 * * * ?")); // every 5 minutes
});
 
builder.Services.AddQuartzHostedService(opts =>
{
    opts.WaitForJobsToComplete = true; // let running jobs finish on shutdown
});
 
var app = builder.Build();
app.Run();

The cron string 0 0/5 * * * ? means "at second 0, every 5th minute". Quartz cron has seconds as the first field, which is different from Linux cron. Keep that in mind so your job does not fire 60 times more often than you expect.

Run the app. Every 5 minutes you see the log line. But stop the app and start it again, and Quartz has completely forgotten the schedule. That is the in-memory problem. Now we fix it.

The big switch: memory vs database

Here is the difference side by side.

FeatureIn-memory store (RAMJobStore)Database store (AdoJobStore)
Survives restartNoYes
Survives crashNoYes
Multiple servers share scheduleNoYes (clustering)
Setup effortAlmost noneNeeds DB tables
SpeedFastestSlightly slower (DB calls)
Good forDemos, tiny appsReal production apps

The trade is simple. The database store does a little more work and needs setup, but it never forgets. For anything real, you want the database store.

Where the schedule lives decides what survives a restart.

Step 4: Understand the QRTZ tables

When Quartz uses a database, it stores everything in a set of tables that all start with QRTZ_. You do not design these tables. Quartz ships the exact SQL for them. Here are the main ones.

TableWhat it holds
QRTZ_JOB_DETAILSYour jobs (name, type, durability)
QRTZ_TRIGGERSAll triggers and their state
QRTZ_CRON_TRIGGERSThe cron expression for cron triggers
QRTZ_FIRED_TRIGGERSTriggers that are firing right now
QRTZ_LOCKSRow locks used for clustering
QRTZ_SCHEDULER_STATEWhich instances are alive

The important rule: Quartz never creates these tables for you, and never migrates them. This is on purpose. The library does not want to alter your database behind your back. So we have to create them. The cleanest way in a .NET app is to put the SQL inside an EF Core migration.

Bringing the schema under control

Official SQL
Empty migration
Embed SQL
db update
Tables exist

Steps

1

Official SQL

From Quartz repo

2

Empty migration

migrations add

3

Embed SQL

migrationBuilder.Sql

4

db update

Apply to DB

5

Tables exist

QRTZ_ ready

The official SQL script becomes a versioned EF Core migration.

Step 5: Create the tables with an EF Core migration

First, make a simple DbContext. You do not need to map the QRTZ tables as entities. The context is just the home for your migrations.

using Microsoft.EntityFrameworkCore;
 
public sealed class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }
 
    // Your normal app tables go here as DbSet<T>.
    // The QRTZ_ tables are created by raw SQL inside a migration.
}

Register it in Program.cs:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Default")));

Now create an empty migration. We use -- so it has no model changes, then we paste the Quartz SQL into it.

// In the terminal:
// dotnet ef migrations add AddQuartzTables

Open the generated migration file. In the Up method, call migrationBuilder.Sql(...) with the official SQL Server script for Quartz (you can copy it from the Quartz.NET GitHub repo, file database/tables/tables_sqlServer.sql). In Down, drop the same tables in reverse order.

public partial class AddQuartzTables : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Paste the full official script here. Shortened for the example:
        migrationBuilder.Sql(@"
            CREATE TABLE [dbo].[QRTZ_JOB_DETAILS] (
                [SCHED_NAME] NVARCHAR(120) NOT NULL,
                [JOB_NAME]  NVARCHAR(150) NOT NULL,
                [JOB_GROUP] NVARCHAR(150) NOT NULL,
                [DESCRIPTION] NVARCHAR(250) NULL,
                [JOB_CLASS_NAME] NVARCHAR(250) NOT NULL,
                [IS_DURABLE] BIT NOT NULL,
                [IS_NONCONCURRENT] BIT NOT NULL,
                [IS_UPDATE_DATA] BIT NOT NULL,
                [REQUESTS_RECOVERY] BIT NOT NULL,
                [JOB_DATA] VARBINARY(MAX) NULL,
                CONSTRAINT [PK_QRTZ_JOB_DETAILS]
                    PRIMARY KEY ([SCHED_NAME],[JOB_NAME],[JOB_GROUP])
            );
            -- ...the rest of the QRTZ_ tables follow here...
        ");
    }
 
    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("DROP TABLE [dbo].[QRTZ_JOB_DETAILS];");
        // ...drop the rest in reverse order of creation...
    }
}

Apply it the same way you apply any migration:

// dotnet ef database update

Now your database has all the QRTZ_ tables, and they are tracked in your migration history just like every other schema change. If you ever upgrade Quartz (for example, Quartz 4 added a MISFIRE_ORIG_FIRE_TIME column), you add a new migration with the upgrade SQL. Clean and versioned.

Step 6: Tell Quartz to use the database

Go back to the AddQuartz call and switch from the default memory store to the persistent store. This is the heart of the whole article.

builder.Services.AddQuartz(q =>
{
    q.UsePersistentStore(store =>
    {
        store.UseProperties = true;     // store job data as simple strings
        store.UseSqlServer(builder.Configuration
            .GetConnectionString("Default")!);
        store.UseSystemTextJsonSerializer(); // modern JSON serializer
 
        // Turn this on to run on many servers safely:
        store.UseClustering(c =>
        {
            c.CheckinInterval = TimeSpan.FromSeconds(10);
            c.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
        });
    });
 
    var jobKey = new JobKey("send-reminders");
    q.AddJob<SendRemindersJob>(opts => opts.WithIdentity(jobKey));
    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("send-reminders-trigger")
        .WithCronSchedule("0 0/5 * * * ?"));
});

That is it. The same jobs and triggers now live in SQL Server. Stop the app, start it again, and the schedule is still there. Quartz reads the tables on startup and carries on.

How a fire actually happens with the database

It helps to see the order of events when a trigger is due.

The scheduler talks to the database before and after running a job.

Because the database is the single source of truth, you can run this same app on three servers. Each one checks the tables, but the row lock in QRTZ_LOCKS makes sure only one server runs each fire. That is clustering, and it gives you both reliability and the ability to scale out.

Clustering: many peons, one timetable

Remember the school bell. With clustering, it is like having three peons who all read the same printed timetable on the wall. When 10:30 comes, only one of them rings the bell, because they agreed on a rule (the lock). If one peon goes home sick, the other two still ring on time. No slot is missed and the bell never rings twice.

To make this work, set a unique instance id and an instance name:

builder.Services.AddQuartz(q =>
{
    q.SchedulerId = "AUTO";            // unique id per running server
    q.SchedulerName = "ReminderScheduler";
    // ...UsePersistentStore as above...
});

AUTO tells Quartz to generate a different id for each instance, which is exactly what clustering needs.

Common mistakes to avoid

  • Forgetting that Quartz cron starts with seconds. 0/5 * * * * ? runs every 5 seconds, not minutes. Double-check your expression.
  • Letting the app auto-create tables. Quartz will not. Run your EF Core migration first, or the app will throw a "table not found" error at startup.
  • Skipping [DisallowConcurrentExecution] on long jobs. Without it, a slow job can pile up on itself.
  • Using the in-memory store in production. It looks fine in a demo, then loses every schedule on the first deploy.
  • Different SchedulerName across cluster nodes. All nodes in one cluster must share the same scheduler name, or they will not see each other.

Quick recap

  • Quartz.NET is a job scheduler for .NET: a Job is the work, a Trigger is the when, and the Scheduler fires them.
  • The default in-memory store forgets everything on restart. Real apps need persistence.
  • AdoJobStore writes jobs and triggers into QRTZ_ tables in your database, so the schedule survives restarts and crashes.
  • Quartz does not create or migrate its own tables. You do it. The cleanest way is an EF Core migration that runs the official Quartz SQL via migrationBuilder.Sql(...).
  • Switch on persistence with q.UsePersistentStore(...) and store.UseSqlServer(...).
  • Turn on clustering to run the same schedule safely across many servers, using a unique SchedulerId of AUTO.
  • Watch out: Quartz cron has seconds first, and always run your migration before the app starts.

References and further reading

Related Posts