Quartz on SQL Server

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

SQL Server is one of the databases that Quartz supports through its official JDBC job store, so a Spring Boot service that already runs on SQL Server (or on Azure SQL) can keep its triggers, job definitions and cluster state in the same engine as its business data. Of the databases covered in this section it is the one whose DDL script needs the most attention: the script that ships in the Quartz jar is written for a client tool such as sqlcmd, not for a JDBC driver, and a Quartz scheduler that uses the default delegate fails at startup because of the way it locks rows. This page walks through the setup in the order in which a reader hits the problems, and every statement about what works or fails was produced by running it. The generic concepts (what the QRTZ_ tables are, initialize-schema, @QuartzDataSource, pool sizing) are on Job Stores & Persistence; the reference database page is Quartz on PostgreSQL, and clustering in general is on Clustering.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026 with the Microsoft JDBC driver mssql-jdbc 13.4.0.jre11 (the version Spring Boot 4.1.1 manages), Flyway 12.4.0, Liquibase 5.0.3, Testcontainers 2.0.5 and the Docker image mcr.microsoft.com/mssql/server:2022-latest, which reported SQL Server 2022 (RTM-CU27) 16.0.4295.3, Developer edition, on Linux, with the server collation SQL_Latin1_General_CP1_CI_AS. That image is published for amd64 only; the tests ran on an Apple Silicon (arm64) machine, where Docker Desktop runs it under Rosetta emulation, and every result below comes from that emulated server. Azure SQL Database was not available and was not tested: everything on this page about Azure is taken from Microsoft’s documentation and is labelled that way. Spring Boot 3.5.16 manages the same Quartz 2.5.2, so everything about Quartz is identical there; only the package of @QuartzDataSource and SchedulerFactoryBeanCustomizer differs (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), Spring Framework is 6.2.19 and the Boot-managed mssql-jdbc, Flyway and Liquibase versions are older.

Status and dependencies

Status: official. The JDBC job store, the MSSQLDelegate and the DDL script are part of Quartz itself and are maintained with it. Nothing about the job store is community code, unlike the MongoDB and Redis stores.

pom.xml: the dependencies for Quartz on SQL Server with Flyway
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.microsoft.sqlserver</groupId>
        <artifactId>mssql-jdbc</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-flyway</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-sqlserver</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-mssqlserver</artifactId>
        <version>2.0.5</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <version>2.0.5</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Source: Spring Boot: Quartz Scheduler and Testcontainers: Microsoft SQL Server Module. All versions except the two Testcontainers ones are managed by Spring Boot 4.1.1 (checked with mvn dependency:tree: mssql-jdbc 13.4.0.jre11, flyway-sqlserver 12.4.0, Quartz 2.5.2). Maven Central lists mssql-jdbc 13.6.0.jre11 as newer; the page was verified with the Boot-managed 13.4.0.

Three details of that list matter. Flyway ships database support as separate modules since version 10, so the project that was tested has flyway-sqlserver next to the Boot starter. The Testcontainers 2.x artifact is testcontainers-mssqlserver and the container class is org.testcontainers.mssqlserver.MSSQLServerContainer (the 1.x class org.testcontainers.containers.MSSQLServerContainer still exists as a deprecated alias). And the Testcontainers module needs an explicit acceptLicense() call, because the image is covered by Microsoft’s end-user licence agreement.

The delegate: MSSQLDelegate

A driver delegate is the class that issues the SQL for one database. The default, StdJDBCDelegate, uses plain JDBC and works on several databases (MySQL and MariaDB among them); engines with a different dialect or a different way of handling binary columns need their own subclass. org.quartz.impl.jdbcjobstore.MSSQLDelegate is the one for SQL Server. Reading its 2.5.2 source, it extends StdJDBCDelegate and overrides exactly three methods:

Table 1. What MSSQLDelegate overrides in Quartz 2.5.2
Method Why

getObjectFromBlob(ResultSet, String)

Reads a serialised object from a VARBINARY(max) column with getBinaryStream instead of java.sql.Blob, and returns null for an empty stream.

getJobDataFromBlob(ResultSet, String)

The same for JOB_DATA; when useProperties is on it hands back the raw stream so that the JobDataMap can be read as properties.

selectTriggerToAcquire(Connection, long, long, int)

Rewrites the "next trigger to acquire" query as SELECT TOP n …​ because T-SQL has no LIMIT clause, and sets maxRows and fetchSize to n.

The delegate does not change the lock statement. That belongs to the job store: JobStoreSupport.initialize looks at the configured driverDelegateClass, and if its name contains the text MSSQLDelegate and selectWithLockSQL is not set, it installs an SQL Server specific one (see Locking: selectWithLockSQL, UPDLOCK and ROWLOCK). The consequence is that the delegate is mandatory, not an optimisation. Spring Boot does not set a delegate (the property is not part of spring.quartz.*), so the effective delegate is StdJDBCDelegate unless you name another one in spring.quartz.properties. The measured behaviour on SQL Server 2022 with the schema in place:

Table 2. Delegate choice on SQL Server, measured with the edited script of the next section
Delegate Result Evidence

none set (Boot’s default, StdJDBCDelegate)

fails at startup

SQLServerException: Line 1: FOR UPDATE clause allowed only for DECLARE CURSOR., reported by Quartz as Failure obtaining db row lock; the scheduler bean is never created

org.quartz.impl.jdbcjobstore.MSSQLDelegate

works

the Bookshelf jobs fire, a dynamically scheduled job with a serialised JobDataMap fires, and after a restart the stored jobs are found

The failure message is worth recognising, because it does not mention Quartz, a delegate or SQL Server’s locking: it comes from the default lock statement, SELECT * FROM QRTZ_LOCKS WHERE …​ FOR UPDATE, which is valid on PostgreSQL, MySQL and Oracle but not in T-SQL, where FOR UPDATE exists only for cursors.

The DDL script and the edits it needs

Quartz ships org/quartz/impl/jdbcjobstore/tables_sqlServer.sql in the quartz-2.5.2.jar. Reading it from the jar gives this anatomy:

Table 3. Anatomy of tables_sqlServer.sql in the Quartz 2.5.2 jar
Part What it is

Comment header

Lines starting with --#: it tells you to set the MSSQLDelegate, to replace the database name on the first line, and quotes an old tip about SelectMethod=cursor and the jTDS driver for a Microsoft driver of the SQL Server 2000 era. Neither tip is needed with mssql-jdbc 13.

USE [enter_db_name_here]

A placeholder: the database the script is meant to run in. It fails if executed as is (below).

47 lines containing only GO

The batch separator of sqlcmd, SSMS and similar tools. GO is not a Transact-SQL statement and SQL Server itself does not understand it.

IF EXISTS (…​) ALTER TABLE …​ DROP CONSTRAINT and IF EXISTS (…​) DROP TABLE

The script drops every foreign key and every QRTZ_ table before creating them. It is a bootstrap script, not a migration.

CREATE TABLE [dbo].[QRTZ_…​] (11 tables)

NVARCHAR for every name and flag column (SCHED_NAME 120, TRIGGER_NAME and TRIGGER_GROUP 200, JOB_CLASS_NAME 250, flags NVARCHAR(1)), BIGINT for times, VARBINARY (max) for JOB_DATA, CALENDAR and BLOB_DATA. The schema [dbo] is hard-coded.

Primary keys, foreign keys, indexes

Clustered primary keys on each table, ON DELETE CASCADE foreign keys from the trigger sub-tables to QRTZ_TRIGGERS, and non-clustered indexes on the four child tables. It has no INSERT statements: the two QRTZ_LOCKS rows are inserted by Quartz on first use.

Source: tables_sqlServer.sql (v2.5.2) and the Spring Boot reference, which says that "the SQL Server, Azure SQL and Sybase scripts may need editing" without saying how (Spring Boot: Quartz Scheduler).

The two edits

Three routes from tables_sqlServer.sql in the Quartz 2.5.2 jar to a SQL Server database. The script has a USE line with a placeholder database name, 47 GO batch separators, drop guards and 11 CREATE TABLE statements. Route 1, Flyway for production: delete the USE line, keep every GO, measured to work because Flyway splits on GO itself. Route 2, Liquibase for production: delete the USE line and use sqlFile with the GO lines kept, or the bundled liquibase.quartz.init.xml, which creates VARCHAR and BIT columns; both measured to work. Route 3, Boot’s initializer for development and tests only: sed deletes the USE line and turns GO into semicolons; it works but the script drops the tables on every start. All three end in the same 11 QRTZ tables, and the database is chosen by the JDBC URL. On every route the driverDelegateClass must be MSSQLDelegate, otherwise the lock statement ends in FOR UPDATE and SQL Server rejects it
Figure 1. Three ways to load the script, and what each needs

Two lines of the script are a problem for anything that is not a SQL client: the USE line and the GO lines. What each one does depends on who runs the script, and these are the measured results on the SQL Server 2022 container. Every row used a fresh database, MSSQLDelegate and spring.quartz.jdbc.initialize-schema=always where Boot ran the script.

Table 4. Running tables_sqlServer.sql through different tools, measured
Who runs it, and how Result What happened

Boot’s initializer, the script untouched, continue-on-error=false

fails

Failed to execute SQL script statement #1, caused by Database 'enter_db_name_here' does not exist

Boot’s initializer, the script untouched, continue-on-error left at its default (true)

fails later, and misleadingly

Boot silently skips the failing statements, so no table is created. The scheduler bean then fails with Invalid object name 'QRTZ_JOB_DETAILS' — the real cause is far away in the log

Boot’s initializer, only the USE line removed, GO kept

fails

Could not find stored procedure 'GO': Spring’s script runner sends GO to the server

Flyway 12.4.0, the script untouched

fails

Database 'enter_db_name_here' does not exist (Flyway reports the script, line 27 and SQL State S0001)

Flyway 12.4.0, the USE line removed, GO kept

works

Flyway understands GO for SQL Server by itself; flyway_schema_history shows version 1 as successful and the 11 QRTZ_ tables exist

Liquibase 5.0.3 sqlFile, the USE line removed, GO kept, default attributes

works

13 tables: the 11 QRTZ_ tables plus Liquibase’s own DATABASECHANGELOG and DATABASECHANGELOGLOCK

Liquibase 5.0.3 sqlFile with splitStatements="true" endDelimiter="\nGO"

works

the same 13 tables

Liquibase 5.0.3 sqlFile, GO replaced by ;

works

the same 13 tables

Boot’s initializer, the USE line removed and every GO line replaced by ;

works

see the sed command below

Two conclusions follow. The USE line must always go, because the database is selected by the connection (see JDBC URL options and, for Azure SQL, Azure SQL Database), not by the script. And the GO lines are a problem only for tools that do not know them: Flyway and Liquibase each cope on their own, while Spring Boot’s own script runner needs them converted. The reason for that last point is a property of the file: it contains no semicolons outside comments, so Spring’s ScriptUtils, which splits on ; and falls back to newlines when it finds none, would cut each multi-line CREATE TABLE into fragments, and a GO line would still be sent as if it were a statement.

The tested edit for Boot’s initializer (development and tests)
# extract the script from the jar, delete the USE line and turn every GO line into a semicolon
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_sqlServer.sql \
  | sed -e '/^USE \[/d' -e 's/^GO$/;/' > src/main/resources/quartz/tables_sqlServer_boot.sql

Source: Spring Boot: Quartz Scheduler. The command reproduces, byte for byte, the file used in the tests (checked with diff).

application-dev.yml: Boot creates the tables from the edited script (development and tests only)
spring:
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: always          # the script DROPS the tables first: never in production
      continue-on-error: false           # surface script errors instead of hiding them
      schema: classpath:quartz/tables_sqlServer_boot.sql
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.MSSQLDelegate

Source: Spring Boot: Quartz Scheduler. Property names and defaults were read from QuartzProperties and the initializer in the 4.1.1 sources.

The script’s first block drops all QRTZ_ tables, so with initialize-schema=always every restart deletes every stored job and trigger (the same pitfall as on every database, measured on PostgreSQL and described on Job Stores & Persistence). Setting continue-on-error: false is the sensible choice when Boot runs this script, because the default of true produced the misleading failure in the table above. (On Spring Boot 3.5.16 the property does not exist and the initializer hard-codes true, so run the script with Flyway or Liquibase there.)

NVARCHAR in 2.5.2, and the fix that is not released

The columns are NVARCHAR in the Quartz 2.5.2 jar: reading tables_sqlServer.sql from the released jar shows [NVARCHAR] for every name and flag column. That change is commit b9e92c33 of 29 November 2025 ("Change SQL Server DDL to use NVARCHAR instead of VARCHAR for perf reasons, see #1422"), and it is included in 2.5.2. Some summaries describe it as a coming 2.5.3 change; that is not accurate, and no 2.5.3 release exists as of this writing (the latest release is 2.5.2).

What is not released yet is a separate fix in the delegate. Pull request #1515, "Bind MSSQL scheduler-state instance name as NVARCHAR", was merged into the Quartz main branch on 28 September 2026. It overrides MSSQLDelegate.updateSchedulerState so that the instance name is bound with PreparedStatement.setNString, and it fixes issue #1514, in which a user reported frequent deadlocks on QRTZ_SCHEDULER_STATE in a cluster: the cluster check-in update binds INSTANCE_NAME with setString, which the reporter says SQL Server then treats as VARCHAR, so the clustered primary key on the NVARCHAR(200) column cannot be used. The override is for SQL Server only.

What was and was not verified here, stated precisely:

  • The 2.5.2 jar contains the NVARCHAR DDL and an MSSQLDelegate without the updateSchedulerState override (read from the sources).

  • The 2.5.2 behaviour in a short cluster test is described in Clustering: no deadlock was observed. The deadlock in #1514 is reported, not reproduced here.

  • The mssql-jdbc documentation says that the driver sends String parameters as Unicode by default (sendStringParametersAsUnicode=true), which would make the reported mismatch depend on the connection settings. That is documentation-derived; the report does not say which settings the reporter used, and this page does not claim to know.

  • Until a release that contains #1515 exists, a deployment that sees check-in deadlocks has two options: check the driver settings, and move to the release when it is published. Do not copy the fix into a custom delegate unless you can test it under load.

Source: Quartz pull request #1515, Quartz issue #1514, commit b9e92c33 and Microsoft: Setting the connection properties. The state of the pull request was read from the GitHub API on 5 October 2026.

Flyway: the production route

In production the schema should be created by a migration tool, run once and recorded, not by the application’s initializer. With Flyway the edit is one line: delete the USE line, keep everything else, and name the file by Flyway’s convention.

src/main/resources/db/migration/V1__quartz_tables.sql: produced by one command
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_sqlServer.sql \
  | sed -e '/^USE \[/d' > src/main/resources/db/migration/V1__quartz_tables.sql

Source: Spring Boot: Database Initialization. The result was run through Flyway 12.4.0 on SQL Server 2022 and created the 11 tables.

application.yml: Flyway creates the tables, Quartz only uses them
spring:
  datasource:
    url: jdbc:sqlserver://${DB_HOST}:1433;databaseName=bookshelf;encrypt=true
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  flyway:
    enabled: true
    locations: classpath:db/migration
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never           # Flyway owns the schema
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.MSSQLDelegate

Source: Spring Boot: Quartz Scheduler. The keys were run with these values, with the container’s URL and credentials in place of the placeholders.

Boot orders the scheduler after the database initializers, so on startup Flyway runs before Quartz reads its tables; that was visible in the tests, where Quartz started on a database that Flyway had just migrated. Three remarks on the migration itself:

  • The script still contains the DROP TABLE guards. They are harmless in a migration that runs once on an empty database, and Flyway never re-runs a versioned migration. If you prefer, delete the guard block; it is plain IF EXISTS statements before the first CREATE TABLE.

  • Do not edit the migration after it was applied. A later change to the Quartz tables, for example when upgrading Quartz, is a new V2__ migration.

  • A Flyway run needs the right to create tables, a Quartz run does not (see A dedicated database, schema and users).

Liquibase: the other production route

Liquibase handles the same script through sqlFile. All three variants in the measured table worked; the shortest one keeps the GO lines and sets no attribute at all.

src/main/resources/db/changelog/db.changelog-master.xml: the Quartz tables as a Liquibase changeset
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
  <changeSet id="quartz-tables" author="bookshelf">
    <sqlFile path="quartz/tables_sqlServer.sql" relativeToChangelogFile="true"
             splitStatements="true" endDelimiter="\nGO"/>
  </changeSet>
</databaseChangeLog>

Source: Liquibase: SQL changelogs and Spring Boot: Database Initialization. The quartz/tables_sqlServer.sql file is the jar script with the USE line removed. This exact changelog, with the file next to it, created the 11 tables on SQL Server 2022 under Liquibase 5.0.3 and the application then ran the Bookshelf jobs.

Liquibase’s documentation says that statements are split on ; or on a go at the end of a line by default, and the measurement agrees: the variant without splitStatements and endDelimiter, and the variant with the GO lines replaced by semicolons, both worked too. splitStatements="true" with endDelimiter="\nGO" is shown above because it states the intent, and it behaves the same. Setting spring.liquibase.change-log is only needed when the changelog is not at Boot’s default classpath:/db/changelog/db.changelog-master.yaml; with the XML name above, set it to classpath:db/changelog/db.changelog-master.xml. The general Liquibase workflow is on Liquibase: Getting Started, and the choice between the tools is on Choosing a Migration Tool.

The bundled liquibase.quartz.init.xml

The Quartz 2.5.2 jar also contains org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, a database-neutral Liquibase changelog (a single changeset, quartz-init, that creates the same 11 tables and uses a blob_type property that is BYTEA on PostgreSQL and BLOB elsewhere). It can be included from a master changelog with <include file="org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml"/>, and it was run on SQL Server 2022 with MSSQLDelegate: it works, the Bookshelf jobs fired and the stored state survived a restart. It produces a different schema from the T-SQL script, though. Querying the catalogue afterwards showed VARCHAR(200) for INSTANCE_NAME (not NVARCHAR), BIT for IS_DURABLE and VARBINARY(max) for JOB_DATA. It also has no [dbo] hard-coding. Choose it when you want one changelog for every database in the fleet, and the T-SQL script when you want the schema that Quartz’s SQL Server maintainers describe and test; do not mix them in one environment.

Source: liquibase.quartz.init.xml (v2.5.2). The column types are from INFORMATION_SCHEMA.COLUMNS of a database created by this file.

A dedicated database, schema and users

Like the PostgreSQL page, this one recommends that Quartz’s tables are kept apart from the business tables and that the runtime user can do less than the migration user. On SQL Server there are two levels of separation: a database of its own (selected by databaseName in the URL, no script change) or a schema of its own in the application’s database. The second needs one more edit, because the script hard-codes [dbo].

Setup run once by an administrator: a schema and two users
-- run in the application's database
CREATE LOGIN quartz_migrator WITH PASSWORD = '<migrator-password>';
CREATE LOGIN quartz_app      WITH PASSWORD = '<runtime-password>';

CREATE USER quartz_migrator FOR LOGIN quartz_migrator;
ALTER ROLE db_owner ADD MEMBER quartz_migrator;       -- creates tables, schema and grants

CREATE USER quartz_app FOR LOGIN quartz_app WITH DEFAULT_SCHEMA = quartz;

Source: Microsoft: CREATE USER. The passwords in the test were random values generated per run; use secrets from your vault.

The migrations: V1 creates the schema, V2 is the script with [dbo] replaced, V3 grants DML only
printf 'CREATE SCHEMA [quartz]\nGO\n' > V1__quartz_schema.sql
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_sqlServer.sql \
  | sed -e '/^USE \[/d' -e 's/\[dbo\]/[quartz]/g' > V2__quartz_tables.sql
printf 'GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::[quartz] TO [quartz_app]\nGO\n' > V3__grant_runtime_user.sql

Source: Microsoft: GRANT Schema Permissions. The three files ran under Flyway 12.4.0; the replacement touches only the bracketed [dbo], so the dbo.sysobjects catalogue view in the drop guards stays as it is.

application.yml: Flyway runs as the migration user, Quartz as the runtime user, and the table prefix names the schema
spring:
  datasource:
    url: jdbc:sqlserver://${DB_HOST}:1433;databaseName=bookshelf;encrypt=true
    username: quartz_app
    password: ${QUARTZ_APP_PASSWORD}
  flyway:
    user: quartz_migrator
    password: ${QUARTZ_MIGRATOR_PASSWORD}
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.MSSQLDelegate
      org.quartz.jobStore.tablePrefix: quartz.QRTZ_

Source: Quartz: Configure JDBC JobStoreTX and Spring Boot: Database Initialization. The same keys, with the test’s values, ran with the Bookshelf jobs on SQL Server 2022: the jobs fired and survived a restart with a runtime user that holds only SELECT, INSERT, UPDATE, DELETE on the schema.

tablePrefix is the property that carries the schema: Quartz substitutes it into every statement, so quartz.QRTZ_ produces quartz.QRTZ_JOB_DETAILS and so on, and with the user’s DEFAULT_SCHEMA set to quartz as well, the unqualified names used by monitoring queries resolve too. The runtime user needs no DDL rights. It does need INSERT as well as SELECT, UPDATE and DELETE, because the QRTZ_LOCKS rows are inserted by Quartz itself on first use.

Locking: selectWithLockSQL, UPDLOCK and ROWLOCK

Quartz uses a lock table, QRTZ_LOCKS, with two rows per scheduler name (TRIGGER_ACCESS and STATE_ACCESS), and takes a lock by selecting one of them with a row lock inside a transaction. The statement is selectWithLockSQL, and the code default in StdRowLockSemaphore is SELECT * FROM {0}LOCKS WHERE SCHED_NAME = {1} AND LOCK_NAME = ? FOR UPDATE, in which {0} is the table prefix and {1} the scheduler name. SQL Server rejects FOR UPDATE, which is the failure of the delegate table above. Instead, JobStoreSupport.initialize contains this block (at line 671 of the 2.5.2 source):

JobStoreSupport.initialize in Quartz 2.5.2: the SQL Server default
if (getDriverDelegateClass() != null && getDriverDelegateClass().contains(MSSQLDelegate.class.getSimpleName())) {
    if (getSelectWithLockSQL() == null) {
        String msSqlDflt = "SELECT * FROM {0}LOCKS WITH (UPDLOCK,ROWLOCK) WHERE " + COL_SCHEDULER_NAME + " = {1} AND LOCK_NAME = ?";
        getLog().info("Detected usage of MSSQLDelegate class - defaulting 'selectWithLockSQL' to '{}'.", msSqlDflt);
        setSelectWithLockSQL(msSqlDflt);
    }
}

Source: JobStoreSupport.java (v2.5.2). The code is quoted from the 2.5.2 sources.

The block runs only when the job store uses database locks, which Spring Boot’s LocalDataSourceJobStore (a JobStoreCMT subclass) always does, and it also runs when the scheduler is not clustered. The INFO message is logged by the job store class, so its logger is org.springframework.scheduling.quartz.LocalDataSourceJobStore, not a org.quartz logger. Setting logging.level.org.springframework.scheduling.quartz=INFO produced exactly this line in the test log:

Detected usage of MSSQLDelegate class - defaulting 'selectWithLockSQL' to 'SELECT * FROM {0}LOCKS WITH (UPDLOCK,ROWLOCK) WHERE SCHED_NAME = {1} AND LOCK_NAME = ?'.
Using db table-based data access locking (synchronization).

Source: Quartz: Configure Clustering with JDBC-JobStore. The two lines are copied from the log of the test run.

The hints do what their names say: UPDLOCK takes an update lock that other UPDLOCK readers wait for (and keeps it until the end of the transaction), and ROWLOCK asks for a row-level lock rather than a page or table lock, so that the two lock rows do not block each other at a coarser level. Because the default is installed only when selectWithLockSQL is null, a property of your own wins. The hints were observed with two plain JDBC connections against the real server:

Table 5. Two connections and the lock row, measured on SQL Server 2022
Step Result

Connection A runs the Quartz default …​ FOR UPDATE statement

FOR UPDATE clause allowed only for DECLARE CURSOR.

Connection A runs the WITH (UPDLOCK,ROWLOCK) statement in a transaction and reads the row

sys.dm_tran_locks shows a U lock on a KEY resource, an IU lock on the PAGE and an IX lock on the OBJECT: a key-level (row) update lock under the intent locks that SQL Server always takes

Connection B runs the same statement and reads the row (lock timeout 1000 ms)

Lock request time out period exceeded. after 1014 ms: B waits for A

A commits, B runs the statement again

B gets the row

Source: Microsoft: Table hints (Transact-SQL). The test read the first row of each result, because the driver returns from executeQuery before the row lock is requested. It is worth knowing when writing such a probe yourself: a statement that is blocked on the lock reports it when the result is read, not when it is sent.

By default, trigger acquisition takes no row lock: JobStoreSupport.acquireNextTriggers claims each trigger with a conditional UPDATE from WAITING to ACQUIRED, and a node whose UPDATE affects 0 rows simply moves on. The WITH (UPDLOCK,ROWLOCK) statement on the TRIGGER_ACCESS row is taken afterwards, in triggersFired and triggeredJobComplete, and that is where a second node waits. Acquisition runs inside the same lock only when org.quartz.jobStore.acquireTriggersWithinLock=true or org.quartz.scheduler.batchTriggerAcquisitionMaxCount is greater than 1.

sequenceDiagram participant A as Node A participant DB as SQL Server (QRTZ_LOCKS, QRTZ_TRIGGERS) participant B as Node B A->>DB: UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = ACQUIRED WHERE ... TRIGGER_STATE = WAITING B->>DB: same UPDATE for the same trigger DB-->>A: 1 row updated, A owns the trigger DB-->>B: 0 rows updated, B moves on A->>DB: triggersFired, SELECT * FROM QRTZ_LOCKS WITH (UPDLOCK,ROWLOCK) WHERE LOCK_NAME = TRIGGER_ACCESS DB-->>A: row, key U lock held until commit B->>DB: same SELECT for its own triggersFired or triggeredJobComplete Note over B,DB: B waits for the row lock A->>DB: INSERT QRTZ_FIRED_TRIGGERS, UPDATE the trigger, COMMIT DB-->>B: row, key U lock now held by B opt acquireTriggersWithinLock=true or batchTriggerAcquisitionMaxCount > 1 A->>DB: SELECT ... WITH (UPDLOCK,ROWLOCK) on TRIGGER_ACCESS before acquiring A->>DB: SELECT TOP n ... WHERE TRIGGER_STATE = WAITING, UPDATE to ACQUIRED A->>DB: COMMIT releases the lock end

Source: JobStoreSupport.java (v2.5.2), methods acquireNextTriggers, triggersFired and triggeredJobComplete. The statements are drawn from the source; the behaviour of the lock row itself is the measurement above. The same shape, with FOR UPDATE in place of the hints, is drawn on Quartz on PostgreSQL and Quartz on Oracle.

Isolation level and READ_COMMITTED_SNAPSHOT

Quartz does not change the isolation level unless asked. The job store has two switches, org.quartz.jobStore.txIsolationLevelSerializable (default false, in JobStoreSupport) and txIsolationLevelReadCommitted (default false, in JobStoreCMT), and with Spring’s data source neither is set, so connections run at the driver’s default. On SQL Server that is READ COMMITTED: a new session on the test server reported level 2 (READ COMMITTED) and a plain JDBC connection reported Connection.TRANSACTION_READ_COMMITTED. The correctness of Quartz’s locking does not depend on a higher level, because the lock row serialises the critical sections. The SERIALIZABLE switch exists for engines where that is not true, and on SQL Server it only adds range locks and deadlock risk.

One database option does interact with it: READ_COMMITTED_SNAPSHOT. With it on, plain reads under READ COMMITTED use row versions instead of shared locks, while a statement with UPDLOCK still takes update locks. It is off by default on SQL Server and on by default in Azure SQL Database (Microsoft’s documentation). A two-node cluster test on a database with the option turned on (is_read_committed_snapshot_on was 1) ran for 10 seconds with no duplicate execution and both nodes firing jobs, the same result as with it off. Microsoft’s guide also warns that UPDLOCK under SNAPSHOT isolation (not READ_COMMITTED_SNAPSHOT) can raise error 3960, an update conflict; Quartz does not use snapshot isolation, so avoid setting it as the default isolation of the Quartz connections.

Source: Microsoft: Transaction locking and row versioning guide and JobStoreSupport.java (v2.5.2). For Spring-managed transactions and isolation levels in general, see Transaction Isolation & Locking.

Azure SQL Database

Azure SQL Database was not available for the tests, so this section is derived from Microsoft’s documentation and from what the script and the delegate do; it is not a measurement.

  • No USE. In Azure SQL Database the USE statement can name only the current database; any other database gives error 40508, and "to change databases, you must directly connect to the database" (Microsoft’s USE page). The script’s USE [enter_db_name_here] line must be deleted exactly as above, and the database is part of the connection (databaseName=…​).

  • Same T-SQL, same delegate. The script is plain T-SQL (NVARCHAR, VARBINARY(max), IF EXISTS guards, sysobjects) and MSSQLDelegate is used unchanged; the Spring Boot reference lists Azure SQL together with SQL Server as needing the script edited, and the edits are the same.

  • READ_COMMITTED_SNAPSHOT is on by default, so the two notes in the previous section apply from the first day: the update lock is still taken, and nothing needs to change.

  • Connections need encrypt=true (the driver’s default, see JDBC URL options) and a certificate the JVM trusts; do not use trustServerCertificate=true against Azure.

JDBC URL options

Version 10.2 of the Microsoft driver changed the default of encrypt to true (and since 11.2 the property is a string that also accepts strict), and trustServerCertificate defaults to false. A server with a valid certificate therefore needs no option at all. The Docker image generates a self-signed certificate, so a container needs either encrypt=false or trustServerCertificate=true.

JDBC URLs for production and for the test container
# production: a server with a certificate the JVM trusts
spring.datasource.url=jdbc:sqlserver://${DB_HOST}:1433;databaseName=bookshelf;encrypt=true

# Testcontainers only: a self-signed certificate (this is what MSSQLServerContainer#getJdbcUrl() returns)
# jdbc:sqlserver://localhost:54430;encrypt=false
# alternatively: jdbc:sqlserver://localhost:54430;databaseName=bookshelf;encrypt=false;trustServerCertificate=true

Source: Microsoft: Setting the connection properties. The container URL is the one Testcontainers 2.0.5 returned in the run; the port is random per run.

Do not copy trustServerCertificate=true or encrypt=false into a deployed configuration; they turn off the server authentication that TLS exists for. Two tips in the script’s old header were not needed in any test: SelectMethod=cursor was advice for the SQL Server 2000 driver, and the jTDS driver it mentions is not the Microsoft driver used here. sendStringParametersAsUnicode is true by default; the Microsoft documentation recommends false for VARCHAR columns, but Quartz’s columns are NVARCHAR, so leave it alone unless you have measured a reason (it is also the one setting connected to #1514 above).

Clustering

Clustering needs nothing SQL Server specific beyond what is on this page: the MSSQLDelegate, the lock statement above, and the usual properties on every node.

application.yml additions for a cluster
spring:
  quartz:
    scheduler-name: BookshelfScheduler
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.isClustered: "true"
      org.quartz.jobStore.clusterCheckinInterval: "7500"     # the default; every node must see the same settings
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.MSSQLDelegate

Source: Quartz: Configure Clustering with JDBC-JobStore. The clustered runs below used these keys with a 2000 ms check-in interval.

The measurements, all on the SQL Server 2022 container with the edited script, MSSQLDelegate, a three-thread pool per node and the fast Bookshelf schedule (cache refresh every second, reminders every two seconds):

Table 6. Clustered runs on SQL Server 2022, measured
Run Duplicates Result

Two nodes, 10 s (earlier verification run)

none

work shared between both nodes

Three nodes in one JVM, 15 s, check-in 2000 ms

none

cacheRefresh: 31 distinct scheduled times, 31 executions (8, 6 and 17 per node); overdueLoanReminders: 15 times, 15 executions (5, 8 and 2 per node)

Two nodes, 10 s, READ_COMMITTED_SNAPSHOT on

none

cacheRefresh 18 executions, overdueLoanReminders 9 executions (3 and 6 per node)

Three nodes, 20 s, check-in 300 ms (a probe for #1514)

none

25 and 13 executions, no log line containing deadlock

The same with sendStringParametersAsUnicode=false in the URL

none

25 and 12 executions, no deadlock

Source: Quartz: Configure Clustering with JDBC-JobStore. "Duplicates" means a scheduled fire time executed more than once; the check is made by recording every execution in the test.

Three honest limits apply. The nodes were schedulers in one JVM, so the test shows the locking, not network behaviour. The runs lasted seconds, so they say nothing about the low-probability deadlocks of #1514, which was reported under production load and is not reproduced here: absence of a deadlock in a 20-second probe is not evidence that the report is wrong. And the uneven split of executions between nodes (31 executions of one job: 8, 6 and 17) is normal; Quartz does not load-balance, the first node to take the lock fires. How fail-over and requestsRecovery work is measured on PostgreSQL and described on Clustering.

A Testcontainers test for the Bookshelf jobs

The test starts SQL Server, lets Flyway create the tables from V1__quartz_tables.sql (the script with the USE line removed), starts the Bookshelf service with the JDBC job store and checks that the jobs were stored and that one of them fires. The Bookshelf schedule is the one from Getting Started with Spring Boot, with a property that makes the cache refresh fire every second.

BookshelfSqlServerIT.java: the Bookshelf jobs on a real SQL Server
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mssqlserver.MSSQLServerContainer;

@SpringBootTest(properties = {
        "bookshelf.fast=true",
        "spring.quartz.job-store-type=jdbc",
        "spring.quartz.jdbc.initialize-schema=never",
        "spring.quartz.scheduler-name=BookshelfScheduler",
        "spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO",
        "spring.quartz.properties.org.quartz.jobStore.isClustered=true",
        "spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.MSSQLDelegate"
})
@Testcontainers
class BookshelfSqlServerIT {

    @Container
    @ServiceConnection
    static final MSSQLServerContainer SQL_SERVER =
            new MSSQLServerContainer("mcr.microsoft.com/mssql/server:2022-latest").acceptLicense();

    @Autowired
    Scheduler scheduler;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void bookshelfJobsAreStoredInSqlServerAndFire() throws Exception {
        assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
        assertThat(scheduler.getMetaData().isJobStoreSupportsPersistence()).isTrue();
        assertThat(scheduler.getMetaData().isJobStoreClustered()).isTrue();

        // Flyway created the tables, Boot's initializer did nothing, the Bookshelf beans were stored on startup
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS", Integer.class)).isEqualTo(4);
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_TRIGGERS", Integer.class)).isEqualTo(4);

        // the one-second cache refresh fires and the store records it (PREV_FIRE_TIME is -1, not NULL, until the first fire)
        await().atMost(Duration.ofSeconds(30)).untilAsserted(() ->
                assertThat(jdbc.queryForObject(
                        "SELECT COUNT(*) FROM QRTZ_TRIGGERS WHERE TRIGGER_NAME = 'cacheRefreshTrigger' AND PREV_FIRE_TIME > 0",
                        Integer.class)).isEqualTo(1));

        // this node checked in to the cluster table
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_SCHEDULER_STATE WHERE SCHED_NAME = 'BookshelfScheduler'",
                Integer.class)).isEqualTo(1);
    }
}

Source: Testcontainers: Microsoft SQL Server Module and Spring Boot: Testcontainers. The test was run on 5 October 2026 and passed (1 test, 0 failures, about 50 seconds including the image start).

What the run established, stated plainly because the page is the only place it is written down:

  • The container is mcr.microsoft.com/mssql/server:2022-latest, an amd64 image, run under Rosetta emulation on an arm64 Mac. It started in about eight seconds, the 47 batches of the migration ran, and the test passed. On an amd64 CI runner no emulation is involved; the image is amd64 only, so an arm64 host needs an emulation layer.

  • @ServiceConnection works with the 2.x MSSQLServerContainer: it supplied the connection details of the container to the Boot data source, so no @DynamicPropertySource is needed.

  • The container’s default database is master and the user is sa; that is acceptable for a throw-away container, and the test therefore does not need a databaseName. A test for the dedicated-schema setup above creates a database and users first, with a plain JDBC connection to the container, and that variant was also run.

  • The same Bookshelf scenario (the four stored jobs and triggers, a dynamically scheduled job whose JobDataMap holds a serialisable record, and a restart with initialize-schema=never in which the job’s persisted counter kept growing) passed on SQL Server with each route in the table of edits that is marked as working.

The wider Testcontainers setup (reuse, @ServiceConnection, CI runners) is on Spring Boot Integration Tests with Testcontainers.

Business data and Quartz data on SQL Server

If the business data is also on SQL Server, the simplest layout is one Hikari data source, which Boot shares with Quartz: a single DataSource bean is picked up for the job store automatically. With a second data source you need @QuartzDataSource (and @QuartzTransactionManager when the Quartz data source has its own transaction manager), exactly as on the Job Stores & Persistence page, and the pitfall measured there applies here too: two data sources with no @Primary make Boot silently fall back to the RAM store, and @QuartzDataSource does not rescue that (mark the application’s data source @Primary and Quartz’s @QuartzDataSource). Entities and repositories are covered on Spring Data JPA; the general SQL material of this site is under SQL.

Collation can matter on SQL Server, and it was tested: a database created with the case-sensitive collation Latin1_General_100_CS_AS ran the Bookshelf jobs and the restart check without any change, because Quartz addresses its tables and columns in upper case, as the script creates them. The pitfall is on your side: monitoring queries and any code that writes qrtz_triggers in lower case would fail on such a database.

Pitfalls

Table 7. SQL Server pitfalls, each from a run or from the sources
Symptom Cause Fix

Database 'enter_db_name_here' does not exist

the script’s first statement is USE [enter_db_name_here]

delete that line (sed -e '/^USE \[/d'); the database comes from the JDBC URL

Could not find stored procedure 'GO'

Spring’s script runner sent a GO line to the server

for Boot’s initializer, convert GO lines to ;; Flyway and Liquibase need no conversion

FOR UPDATE clause allowed only for DECLARE CURSOR and Failure obtaining db row lock

the default StdJDBCDelegate is in use, so the lock statement ends with FOR UPDATE

set org.quartz.jobStore.driverDelegateClass to org.quartz.impl.jdbcjobstore.MSSQLDelegate

Invalid object name 'QRTZ_JOB_DETAILS' after startup

Boot’s initializer ran the script, continue-on-error is true by default and the script’s failing statements were skipped, so no table exists

set continue-on-error: false while debugging, and create the tables with Flyway or Liquibase

Stored jobs and triggers vanish after every restart

initialize-schema=always re-runs a script that drops the tables first

initialize-schema=never outside development; migrations create the schema

A schema other than dbo is ignored or fails

the script hard-codes [dbo]

replace [dbo] in the script and set org.quartz.jobStore.tablePrefix to schema.QRTZ_

A dedicated runtime user cannot start the scheduler

the user lacks INSERT (Quartz inserts the QRTZ_LOCKS rows) or UPDATE and DELETE on the tables

grant SELECT, INSERT, UPDATE, DELETE on the schema; no DDL rights are needed

Connection fails with a certificate or TLS error against a container

mssql-jdbc encrypts by default (since 10.2) and the image has a self-signed certificate

encrypt=false or trustServerCertificate=true, for the container only

Two DataSource beans and no persistence at all

neither is @Primary, so Boot silently uses the RAM store (a @QuartzDataSource does not change that)

mark the application’s data source @Primary and the Quartz one with @QuartzDataSource

Check-in deadlocks on QRTZ_SCHEDULER_STATE under load

reported in #1514; fixed on Quartz main by #1515, unreleased; not reproduced in the tests on this page

check the driver and connection settings and move to a release that contains #1515 when one exists

The Docker image does not start on an arm64 machine without emulation

mssql/server:2022-latest is amd64 only

enable Rosetta (Docker Desktop on Apple Silicon) or run the tests on amd64 CI

The same schema script is applied to Azure SQL Database and fails on USE

USE cannot switch databases there (error 40508)

remove the line, as everywhere

Source: the measurements and sources cited in the sections above.

What changed

Table 8. Facts about Quartz on SQL Server that changed or that older articles get wrong
Topic Before Now (Quartz 2.5.2, Boot 4.1.1)

Column types in tables_sqlServer.sql

VARCHAR names and flags

NVARCHAR since commit b9e92c33 (29 November 2025), in the released 2.5.2 jar; it was not postponed to a 2.5.3

Binding of the instance name in the check-in update

setString (all releases up to 2.5.2)

setNString on Quartz main since PR #1515 (28 September 2026), unreleased; fixes #1514

Delegates

older articles mention DB2v8Delegate and CloudscapeDelegate as if they were the family to choose from

MSSQLDelegate is the SQL Server one; StdJDBCDelegate does not work on SQL Server because of FOR UPDATE

Spring Boot reference

"may need editing", with no detail

the edits are the USE line and, for Boot’s initializer only, the GO lines (this page); Flyway understands GO

mssql-jdbc defaults

encrypt=false up to 9.4

encrypt=true since 10.2; encrypt is a string since 11.2 and accepts strict

Flyway

one artifact for every database

database modules since Flyway 10: add flyway-sqlserver

Testcontainers

org.testcontainers:mssqlserver and org.testcontainers.containers.MSSQLServerContainer

org.testcontainers:testcontainers-mssqlserver 2.0.5 and org.testcontainers.mssqlserver.MSSQLServerContainer

Spring Boot 3.5.16

same Quartz 2.5.2 and the same behaviour

only the package of the Quartz auto-configuration types and the managed driver and migration-tool versions differ

The complete list of version changes and documentation discrepancies of the section is on Migration & What’s Changed.

Where to go next