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.
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
Steps
Job
Write an IJob
Schedule
Trigger with cron
Persist
Use AdoJobStore
Migrate
Create QRTZ tables
Survive
Restart-proof
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 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 toolingQuartz.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.
| Feature | In-memory store (RAMJobStore) | Database store (AdoJobStore) |
|---|---|---|
| Survives restart | No | Yes |
| Survives crash | No | Yes |
| Multiple servers share schedule | No | Yes (clustering) |
| Setup effort | Almost none | Needs DB tables |
| Speed | Fastest | Slightly slower (DB calls) |
| Good for | Demos, tiny apps | Real 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.
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.
| Table | What it holds |
|---|---|
QRTZ_JOB_DETAILS | Your jobs (name, type, durability) |
QRTZ_TRIGGERS | All triggers and their state |
QRTZ_CRON_TRIGGERS | The cron expression for cron triggers |
QRTZ_FIRED_TRIGGERS | Triggers that are firing right now |
QRTZ_LOCKS | Row locks used for clustering |
QRTZ_SCHEDULER_STATE | Which 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
Steps
Official SQL
From Quartz repo
Empty migration
migrations add
Embed SQL
migrationBuilder.Sql
db update
Apply to DB
Tables exist
QRTZ_ ready
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 AddQuartzTablesOpen 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 updateNow 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.
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
SchedulerNameacross 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(...)andstore.UseSqlServer(...). - Turn on clustering to run the same schedule safely across many servers, using a unique
SchedulerIdofAUTO. - Watch out: Quartz cron has seconds first, and always run your migration before the app starts.
References and further reading
- Job Stores — Quartz.NET Official Tutorial
- Microsoft DI Integration — Quartz.NET
- Database Schema for Quartz.NET
- EF Core Migrations Overview — Microsoft Learn
- Using Quartz.NET with ASP.NET Core and worker services — Andrew Lock
- Quartz.NET 4.x Migration Guide
Related Posts
EF Core Migrations: A Detailed Beginner Guide for .NET
Learn EF Core migrations step by step. Add, apply, revert, and ship database changes safely with simple examples, diagrams, tables, and best practices for .NET 10.
How to Manage EF Core DbContext Lifetime: A Beginner's Guide
Learn how to manage EF Core DbContext lifetime safely. Understand scoped, transient, singleton, pooling, and DbContextFactory with simple examples and diagrams.
How to Create Migrations for Multiple Databases in EF Core (.NET 10)
Learn how to create EF Core migrations for multiple databases like SQL Server, SQLite, and PostgreSQL using separate migration projects, with simple examples.
Scheduling Background Jobs with Quartz.NET: Advanced Concepts
Go deeper with Quartz.NET in .NET 10: persistent job stores, clustering, misfire handling, cron triggers, calendars, and safe retries for real production jobs.
Multi-Tenant Applications With EF Core: A Beginner's Guide
Learn multi-tenancy in EF Core the simple way. Isolate tenant data with global query filters, ITenantService, and named filters in .NET 10, with diagrams and code.
Using Multiple EF Core DbContext in a Single Application
Learn how to use multiple EF Core DbContext classes in one .NET app. See when to split, how to register, migrate, and coordinate them with simple examples.