Tips
JSON is the recommended format for data a job store persists. Consider also setting StoreJobDataAsStrings, which keeps job data out of the serializer altogether by restricting it to strings.
Tips
System.Text.Json serialization is built into the Quartz package and is the default; see Serialization (System.Text.Json).
JSON.NET
Quartz.Serialization.Newtonsoft provides JSON serialization support for job stores using Json.NET to handle the actual serialization process.
Installation
You need to add NuGet package reference to your project which uses Quartz.
dotnet add package Quartz.Serialization.Newtonsoft
Configuring
Configuring the store
builder.Services.AddQuartz(q => q.UsePersistentStore(store =>
{
store.UseSqlServer(connectionString);
// it's generally recommended to stick with
// string property keys and values when serializing
store.ConfigureStore(options => options.StoreJobDataAsStrings = true);
store.UseNewtonsoftJsonSerializer();
}));
Without a host, the same calls go inside QuartzSchedulerBuilder.Create(q => …):
await using StandaloneSchedulerFactory schedulerFactory = QuartzSchedulerBuilder
.Create(q => q.UsePersistentStore(store =>
{
store.UseGenericDatabase("MyProvider", "my connection string");
store.ConfigureStore(options => options.StoreJobDataAsStrings = true);
store.UseNewtonsoftJsonSerializer();
}))
.Build();
Build() returns a StandaloneSchedulerFactory, which owns the container it built: dispose it — with await using, as above — and the scheduler shuts down with it.
Classic property-based configuration
The flat keys 3.x used still work, and mean the same thing:
NameValueCollection properties = new()
{
["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.LocalTransactionJobStore, Quartz",
["quartz.serializer.type"] = "newtonsoft"
};
await using StandaloneSchedulerFactory schedulerFactory = QuartzSchedulerBuilder.Create()
.UseProperties(properties)
.Build();
UseGenericDatabase is the right method only for a database Quartz has no specific support for; use UseSqlServer, UsePostgres and the rest otherwise. If Quartz ships no description of your ADO.NET driver either, describe it in the same call — see the configuration reference.
What a job data map may hold
A job data value has to be one of the types JobDataMap declares an accessor for — string, bool, char, the numeric types, DateTime, DateTimeOffset, TimeSpan, Guid, DateOnly, TimeOnly, an enum — or a Dictionary<string, string>; anything else is refused when the job or trigger is stored, with a Quartz.JsonSerializationException naming the entry and the type, rather than written as a blob that fails to load on the next fire. That is the same set the System.Text.Json serializer accepts, and literally the same declaration, so a value one of them writes is a value the other's reader has an answer for — down to the bytes: a Dictionary<string, string> is written as a plain JSON object here as well, where this serializer used to name the type it had written the map under.
The one name a string map's own entries cannot use is $type. That is where Json.NET writes a value's type, so both readers take it as metadata rather than data, and a map that stores an entry under it is refused along with everything else neither reader could hand back.
To store a type of your own, declare it — which is your word that Json.NET can build it back, and a versioning commitment for as long as the value sits in the database:
builder.Services.AddQuartz(q => q.UsePersistentStore(store =>
{
store.UseNewtonsoftJsonSerializer(json =>
{
// Without this, a ReportOptions in a JobDataMap is refused when the job is stored.
json.AddJobDataValueType<ReportOptions>();
});
}));
A JobKey or TriggerKey held as a job data value takes the same declaration. A TimeZoneInfo and a nested JobDataMap are past declaring, because Json.NET cannot read either back out of what it writes — store a zone's Id, and serialize a nested structure in the job and keep the result as a string. A string is also the answer when the value has to survive a change of serializer, since a declared type is read back by the serializer that wrote it and by no other.
Migrating from binary serialization
Quartz 4 no longer ships the BinaryObjectSerializer: the underlying BinaryFormatter has been removed from modern .NET and throws on .NET 9 and later. If you still have binary-serialized data in your database you need to migrate it to JSON.
The recommended path is to perform the migration while you are still on Quartz 3.x, which still includes BinaryObjectSerializer - see the Quartz 3.x version of this page for a ready-made hybrid serializer. Either let the system migrate gradually as it runs, or write a small program that loads and writes back every serialized asset in the database.
Which blobs rewrite themselves, and which never do
"Let the system migrate gradually as it runs" is only half a plan, because two of the four blob columns are never written by running. What the store actually writes, on 3.x and 4.x alike:
| Column | Rewritten by running? |
|---|---|
QRTZ_BLOB_TRIGGERS.BLOB_DATA | Yes. The whole trigger is re-serialized on every write to the trigger, firings included. |
QRTZ_TRIGGERS.JOB_DATA | Only if the map changed. The trigger UPDATE leaves the column out entirely when JobDataMap.Dirty is false, so a trigger whose data nobody touches fires forever without rewriting it. |
QRTZ_JOB_DETAILS.JOB_DATA | Only if the job is stored again, or after a firing when the job carries [PersistJobDataAfterExecution] and the map was modified. Both conditions, not either. |
QRTZ_CALENDARS.CALENDAR | Never. Only AddCalendar writes it, so a calendar added once at deployment is never rewritten however long the scheduler runs. |
So the calendars are the ones a gradual migration silently leaves behind, and the job data maps are the ones it leaves behind for every job that does not write to its own map. Anything belonging to a paused trigger, or to one whose next fire time is months out, is not touched until it resumes or fires either. A program that loads and writes back every asset is the only approach that finishes; the SchedulerConstants.ForceJobDataMapDirty key is the lever that makes a loaded map look modified, so a re-store writes it.
BLOB_TRIGGERS.BLOB_DATA migrates itself as the scheduler runs and still has to be done on 3.x — see below — because on 4.x it cannot be read at all.
Finding what is left
A BinaryFormatter payload begins 0x00 0x01 0x00 0x00 0x00; a JSON one begins {, which is 0x7B. One byte is enough to tell them apart, so each dialect can count what is still binary:
SQL Server
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE SUBSTRING(JOB_DATA, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE SUBSTRING(JOB_DATA, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE SUBSTRING(CALENDAR, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE SUBSTRING(BLOB_DATA, 1, 1) = 0x00;
PostgreSQL
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE GET_BYTE(JOB_DATA, 0) = 0
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE GET_BYTE(JOB_DATA, 0) = 0
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE GET_BYTE(CALENDAR, 0) = 0
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE GET_BYTE(BLOB_DATA, 0) = 0;
MySQL
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE SUBSTRING(JOB_DATA, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE SUBSTRING(JOB_DATA, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE SUBSTRING(CALENDAR, 1, 1) = 0x00
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE SUBSTRING(BLOB_DATA, 1, 1) = 0x00;
Oracle
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE DBMS_LOB.SUBSTR(JOB_DATA, 1, 1) = HEXTORAW('00')
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE DBMS_LOB.SUBSTR(JOB_DATA, 1, 1) = HEXTORAW('00')
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE DBMS_LOB.SUBSTR(CALENDAR, 1, 1) = HEXTORAW('00')
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE DBMS_LOB.SUBSTR(BLOB_DATA, 1, 1) = HEXTORAW('00');
SQLite
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE HEX(SUBSTR(JOB_DATA, 1, 1)) = '00'
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE HEX(SUBSTR(JOB_DATA, 1, 1)) = '00'
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE HEX(SUBSTR(CALENDAR, 1, 1)) = '00'
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE HEX(SUBSTR(BLOB_DATA, 1, 1)) = '00';
Firebird
SELECT 'QRTZ_JOB_DETAILS' AS SOURCE, COUNT(*) AS STILL_BINARY FROM QRTZ_JOB_DETAILS WHERE CAST(SUBSTRING(JOB_DATA FROM 1 FOR 1) AS VARCHAR(1) CHARACTER SET OCTETS) = x'00'
UNION ALL SELECT 'QRTZ_TRIGGERS', COUNT(*) FROM QRTZ_TRIGGERS WHERE CAST(SUBSTRING(JOB_DATA FROM 1 FOR 1) AS VARCHAR(1) CHARACTER SET OCTETS) = x'00'
UNION ALL SELECT 'QRTZ_CALENDARS', COUNT(*) FROM QRTZ_CALENDARS WHERE CAST(SUBSTRING(CALENDAR FROM 1 FOR 1) AS VARCHAR(1) CHARACTER SET OCTETS) = x'00'
UNION ALL SELECT 'QRTZ_BLOB_TRIGGERS', COUNT(*) FROM QRTZ_BLOB_TRIGGERS WHERE CAST(SUBSTRING(BLOB_DATA FROM 1 FOR 1) AS VARCHAR(1) CHARACTER SET OCTETS) = x'00';
Replace QRTZ_ with your configured table prefix. A null column is neither binary nor JSON, so no clause counts it. Zero everywhere means the gradual migration finished; anything else names the table to go and rewrite by hand, and in practice QRTZ_CALENDARS is the one still holding rows.
If you must read legacy binary data after upgrading to Quartz 4 on .NET 9 or later, you can re-enable BinaryFormatter with Microsoft's unsupported compatibility package. Because the package does not change BinaryFormatter's type identity, only your application project needs it - Quartz itself does not reference it:
<PropertyGroup>
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
<ItemGroup>
<!-- match the package's major version to your application's target framework -->
<PackageReference Include="System.Runtime.Serialization.Formatters" Version="10.0.0" />
</ItemGroup>
The package restores a working - but still unsafe - BinaryFormatter, so read the Microsoft guidance before relying on it and remove it once the migration is complete. The Quartz types a blob can be made of - the job data maps, the keys that can sit in them as values, the calendars and the trigger classes - keep their [Serializable] / ISerializable support, so the hybrid serializer below can read the old binary payloads and write everything back as JSON. Types that could never be part of a blob lost those attributes in 4.0; see the migration guide for the full list.
A blob whose job data holds a key, or a class of the application's own, needs that type declared with AddJobDataValueType<T>() on the registry the migrator's inner serializer is built from — otherwise the value reads out of the binary payload and is refused on the way back in, which is the gate described above doing its job at the one moment it is unwelcome.
Blob triggers cannot be migrated from 4.x
One column is the exception: BLOB_TRIGGERS.BLOB_DATA holds whole trigger objects, and BinaryFormatter records private base-class fields under the base class's name - which 4.0 renamed (AbstractTrigger is TriggerBase) and whose field set 4.0 extended. Migrate binary blob triggers while still on 3.x; the hybrid serializer on 4.x is for the job data map, key and calendar payloads.
Example hybrid serializer
using System.Runtime.Serialization.Formatters.Binary;
using Newtonsoft.Json;
using Quartz.Impl;
using Quartz.Extensibility;
namespace Quartz;
public sealed class MigratorSerializer : IObjectSerializer
{
// you might need custom configuration, see sections about customizing in documentation
private readonly NewtonsoftJsonObjectSerializer jsonSerializer = new();
public T Deserialize<T>(byte[] data) where T : class
{
try
{
// Attempt to deserialize data as JSON
return jsonSerializer.Deserialize<T>(data)!;
}
catch (JsonReaderException)
{
// The data was not JSON, so fall back to the legacy binary format. This branch needs
// the System.Runtime.Serialization.Formatters compatibility package and
// EnableUnsafeBinaryFormatterSerialization to be set in the application project.
using var stream = new MemoryStream(data);
#pragma warning disable SYSLIB0011
var binaryData = (T) new BinaryFormatter().Deserialize(stream);
#pragma warning restore SYSLIB0011
if (binaryData is JobDataMap jobDataMap)
{
// make sure we mark the map as dirty so it will be serialized as JSON next time
jobDataMap[SchedulerConstants.ForceJobDataMapDirty] = "true";
}
return binaryData;
}
}
public byte[] Serialize<T>(T obj) where T : class => jsonSerializer.Serialize(obj);
}
Customizing JSON.NET
If you need to customize JSON.NET settings, you need to inherit custom implementation and override CreateSerializerSettings.
class CustomJsonSerializer : NewtonsoftJsonObjectSerializer
{
protected override JsonSerializerSettings CreateSerializerSettings()
{
var settings = base.CreateSerializerSettings();
settings.Converters.Add(new MyCustomConverter());
return settings;
}
}
And then configure it to use
store.UseSerializer<CustomJsonSerializer>();
or, as a flat property key:
quartz.serializer.type = MyProject.CustomJsonSerializer, MyProject
Customizing calendar serialization
If you have implemented a custom calendar, you need to implement a ICalendarSerializer for it. There's a convenience base class CalendarSerializer that you can use the get strongly-typed experience.
Custom calendar and serializer
[Serializable]
class CustomCalendar : BaseCalendar
{
public CustomCalendar()
{
}
// binary serialization support
protected CustomCalendar(SerializationInfo info, StreamingContext context) : base(info, context)
{
SomeCustomProperty = info?.GetBoolean("SomeCustomProperty") ?? true;
}
public bool SomeCustomProperty { get; set; } = true;
// binary serialization support
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info?.AddValue("SomeCustomProperty", SomeCustomProperty);
}
}
// JSON serialization support
class CustomCalendarSerializer : CalendarSerializer<CustomCalendar>
{
protected override CustomCalendar Create(JObject source)
{
return new CustomCalendar();
}
protected override void SerializeFields(JsonWriter writer, CustomCalendar calendar)
{
writer.WritePropertyName("SomeCustomProperty");
writer.WriteValue(calendar.SomeCustomProperty);
}
protected override void DeserializeFields(CustomCalendar calendar, JObject source)
{
calendar.SomeCustomProperty = source["SomeCustomProperty"]!.Value<bool>();
}
}
A serializer can optionally override CalendarTypeName to give the calendar a serializer-neutral name — the same discriminator the System.Text.Json package would use for it. The registry then finds the serializer under that name as well as under the calendar's assembly-qualified type name, so a payload written by either package resolves. Leave it unset and the serializer answers only to the assembly-qualified name, which is what payloads written by 3.x carry.
Configuring custom calendar serializer
builder.Services.AddQuartz(q => q.UsePersistentStore(store =>
{
store.UseNewtonsoftJsonSerializer(json =>
{
json.AddCalendarSerializer<CustomCalendar>(new CustomCalendarSerializer());
});
}));
Changed in 4.0
NewtonsoftJsonObjectSerializer.AddCalendarSerializer and AddTriggerSerializer were static in 3.x, so every scheduler in the process shared one set of custom serializers and registration order silently decided which one won. They have been removed. Register through the UseNewtonsoftJsonSerializer callback as above: what the callback registers belongs to that scheduler alone, so two schedulers in one container can serialize different custom types.
If you build a serializer yourself rather than through the store builder, hand it a NewtonsoftJsonSerializerRegistry. A new registry already knows every built-in trigger and calendar type, so registering a custom one adds to that set:
NewtonsoftJsonSerializerRegistry registry = new NewtonsoftJsonSerializerRegistry()
.AddCalendarSerializer<CustomCalendar>(new CustomCalendarSerializer())
.AddTriggerSerializer<CustomTrigger>(new CustomTriggerSerializer());
NewtonsoftJsonObjectSerializer serializer = new(registry);
