Quartz on Embedded Databases (H2, HSQLDB, Derby) and Other SQL Engines

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.

The first database a Quartz user meets is usually not PostgreSQL but whatever starts without any setup: an in-memory database on a laptop or inside a unit test. Spring Boot supports that case explicitly. If H2, HSQLDB or Derby is on the classpath and spring.quartz.job-store-type=jdbc is set, Boot creates the QRTZ_ tables by itself and the whole Quartz stack runs without Docker, without credentials and without a server. This page explains exactly when that automatic setup happens (and when it silently does not), which script and which delegate each of the three engines needs, how the engines behave under Quartz’s row locking, how to manage the same schema with Flyway or Liquibase, and how to split one application.yml into a development profile on an embedded database and a production profile on PostgreSQL. A short table at the end covers the other engines that Quartz ships scripts for (DB2, Sybase, CUBRID, GaussDB, InterSystems Cache and several historical ones).

It belongs to the database pages of the job stores and persistence chapter, which holds the generic material (the QRTZ_ tables, tablePrefix, delegates, the schema-management properties) and the database support matrix. The reference database of the section is PostgreSQL; the other production databases are on MySQL and MariaDB, Oracle and SQL Server. How to test jobs is on Testing, and the cluster semantics are 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: every behaviour below was run in a test project (the results are quoted, not assumed), and every default and class name was read from the Quartz 2.5.2 and Spring Boot 4.1.1 sources. The code was compiled for Java 21 and run on JDK 25.0.2 with Maven 3.9.16. The embedded engines are the versions that Spring Boot 4.1.1 manages: H2 2.4.240, HSQLDB 2.7.3 and Apache Derby 10.16.1.1. The migration tools are the Boot-managed Flyway 12.4.0 and Liquibase 5.0.3, and the production half of the profile example ran against the postgres:17 image with PostgreSQL JDBC 42.7.13. Spring Boot 3.5.16 manages the same Quartz 2.5.2, so the Quartz side is identical there; the Boot package of the Quartz classes is org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure, Spring Framework is 6.2.19, and the managed engine versions (from the Boot 3.5.16 build file, not re-run) are H2 2.3.232, HSQLDB 2.7.3, Derby 10.16.1.1, Flyway 11.7.2 and Liquibase 4.31.1.

When an embedded database is the right choice

An embedded database runs inside the application’s JVM, so it needs no installation and starts quickly. For Quartz that is attractive for three jobs and unsuitable for a fourth.

Table 1. What embedded engines are good for with Quartz
Use Verdict Why

Developer laptop, run the service without a database container

Good

The jobs, the triggers and the JobDataMap serialization are the real JDBC code path, only the engine is lighter. The dev profile below does exactly this.

Unit and integration tests that need the persistent code path

Good

A named in-memory database per test class is faster than a container and, unlike the RAM store, still serializes job data (see the pitfalls table: a non-serializable value works with the RAM store and fails with every JDBC database).

Demos, workshops, CI smoke tests

Good

No credentials and no network.

Staging and production

No

A file or in-memory database inside one JVM cannot be shared by the second node of a cluster, so there is no failover and no single-execution guarantee across nodes. Use PostgreSQL or another server database, and keep the embedded one for the profiles that are not deployed.

The honest limit is the last row. H2 can be started in a mode that other JVMs can attach to (AUTO_SERVER=TRUE, tested below) but HSQLDB and Derby, as configured in this page, are in-process only. The test results for clustering on the embedded engines are therefore a statement about the lock code, not a recommendation.

What was tested

Every row of the next table ran the same Bookshelf scenario: the five jobs of the section are registered as JobDetail and Trigger beans (the nightly overdue-loan reminders, the cache refresh and so on; a flag shortens the intervals to one or two seconds for tests), a dynamic per-member reminder with a serialized JobDataMap is scheduled at run time, the jobs must fire, and then the application is stopped and started again with initialize-schema=never and must still find its jobs and its @PersistJobDataAfterExecution counter (it went from 4 to 5 on every engine, which proves the state really came from the database).

Table 2. The environments the results on this page come from
Component Version Notes

Quartz

2.5.2

Both Boot 4.1.1 and 3.5.16 manage it.

H2

2.4.240

com.h2database:h2, the Boot-managed version. In-memory, file and AUTO_SERVER URLs were run.

HSQLDB

2.7.3

org.hsqldb:hsqldb. In-memory and file URLs were run.

Apache Derby

10.16.1.1

org.apache.derby:derby plus org.apache.derby:derbytools (the embedded driver class is in derbytools; derbyshared arrives transitively). In-memory and directory URLs were run.

PostgreSQL

postgres:17 image

Only for the production half of the profile example, through Testcontainers 2.0.5 with the PostgreSQL JDBC driver 42.7.13.

Flyway / Liquibase

12.4.0 / 5.0.3

Boot-managed. Flyway needs one extra module per database family (see the Flyway section).

Not run

DB2, Sybase, CUBRID, GaussDB, InterSystems Cache, Informix, Firebird, Pointbase, SapDB, Solid, Cloudscape

No container or no licence was available. They are described in the last section from the jar’s file list and the delegate sources and are marked as such.

How Spring Boot decides to create the tables

Spring Boot’s Quartz auto-configuration registers a script initializer for the Quartz DataSource. The property that controls it is spring.quartz.jdbc.initialize-schema, with the values embedded (the default), always and never. The behaviour of embedded is narrower than its name suggests, and that is the source of most surprises on this page.

flowchart TD A["spring.quartz.job-store-type is jdbc"] --> B{"spring.quartz.jdbc.initialize-schema"} B -->|never| N1["no script runs: the tables must already exist"] B -->|always| Y1["the script runs on every start"] B -->|"embedded (default)"| C{"which DataSource does Quartz use?"} C -->|"H2 with a jdbc:h2:mem URL"| Y2["tables_h2.sql runs"] C -->|"HSQLDB with a jdbc:hsqldb:mem URL"| Y3["tables_hsqldb.sql runs"] C -->|"Derby, whatever the URL"| Y4["tables_derby.sql runs on every start"] C -->|"H2 or HSQLDB with a file or server URL"| N2["nothing runs: start fails with Table not found"] C -->|"any other database"| N3["nothing runs: start fails with Table not found"] Y1 --> D["errors in the script are ignored unless continue-on-error is false"] Y2 --> D Y3 --> D Y4 --> D
Figure 1. When Spring Boot 4.1.1 runs the Quartz script

The rules come from EmbeddedDatabaseConnection in the 4.1.1 source and were each confirmed by a run:

  • The embedded types Boot knows are H2, Derby and HSQLDB. For H2 the URL must contain :h2:mem, for HSQLDB it must contain :hsqldb:mem:, and for Derby any URL counts as embedded.

  • Measured: with the default embedded mode, jdbc:h2:mem:…​, jdbc:hsqldb:mem:…​, jdbc:derby:memory:…​ and jdbc:derby:<directory> all got the 11 tables, while jdbc:h2:file:…​ and jdbc:hsqldb:file:…​ got nothing and the application failed during start with Table "QRTZ_JOB_DETAILS" not found (this database is empty) (H2) and user lacks privilege or object not found: QRTZ_JOB_DETAILS (HSQLDB).

  • Derby is marked deprecated in the 4.1.1 source (@Deprecated(since = "4.1.0", forRemoval = true), "for removal in 4.3.0 as Derby is EOL"). It still works in 4.1.1 (it ran here) but it is the engine to avoid for new code.

  • When no spring.datasource.url is set at all, Boot creates the embedded DataSource itself, with a generated unique name, and takes the first engine it finds on the classpath in the order H2, then Derby, then HSQLDB. Measured with the three on the classpath: H2 (jdbc:h2:mem:<uuid>); with H2 removed: Derby (jdbc:derby:memory:<uuid>); with only HSQLDB: HSQLDB (jdbc:hsqldb:mem:<uuid>). In every case 11 tables were created.

The script that is run is chosen by the @@platform@@ placeholder in spring.quartz.jdbc.schema, whose default is classpath:org/quartz/impl/jdbcjobstore/tables_@@platform@@.sql. The platform is the id of Boot’s DatabaseDriver, lower case, except for a few explicit mappings (DB2 to db2_v95, MySQL and MariaDB to mysql_innodb, PostgreSQL to postgres, SQL Server to sqlServer). For the three embedded engines nothing is remapped, so the names are the plain ids. The platform can be overridden with spring.quartz.jdbc.platform, or the whole script with spring.quartz.jdbc.schema. The lines below were captured with logging.level.org.springframework.jdbc.datasource.init=DEBUG, one run per engine:

The script Boot executes for each engine (DEBUG log of ScriptUtils, timestamps and thread columns removed)
Executing SQL script from class path resource [org/quartz/impl/jdbcjobstore/tables_h2.sql]
Executing SQL script from class path resource [org/quartz/impl/jdbcjobstore/tables_hsqldb.sql]
Executing SQL script from class path resource [org/quartz/impl/jdbcjobstore/tables_derby.sql]
Table 3. The script Boot picks for each engine (all three scripts exist in the 2.5.2 jar)
Engine Platform id Script resource Boot executes Tables created

H2

h2

org/quartz/impl/jdbcjobstore/tables_h2.sql

11

HSQLDB

hsqldb

org/quartz/impl/jdbcjobstore/tables_hsqldb.sql

11

Derby

derby

org/quartz/impl/jdbcjobstore/tables_derby.sql

11

The 11 tables are QRTZ_BLOB_TRIGGERS, QRTZ_CALENDARS, QRTZ_CRON_TRIGGERS, QRTZ_FIRED_TRIGGERS, QRTZ_JOB_DETAILS, QRTZ_LOCKS, QRTZ_PAUSED_TRIGGER_GRPS, QRTZ_SCHEDULER_STATE, QRTZ_SIMPLE_TRIGGERS, QRTZ_SIMPROP_TRIGGERS and QRTZ_TRIGGERS; the generic page explains what each one stores.

Two properties change how failures are reported, and both matter for embedded databases. spring.quartz.jdbc.continue-on-error defaults to true, so a failing statement in the script is skipped silently; and spring.quartz.jdbc.comment-prefix defaults to # and --, which is why the comment lines of every script, in either style, are accepted. Set continue-on-error: false in any test that depends on the script, so a broken schema fails loudly. This needs Spring Boot 4.x: on 3.5.16 the property does not exist (the initializer hard-codes true), so the line has no effect there.

H2

H2 is the default choice: it is the first engine Boot looks for, its script works with the default delegate, and it is the one most teams already have on the test classpath.

Maven dependencies for a Quartz service that uses H2 in development and tests
<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.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>

The Quartz starter brings spring-boot-quartz, Quartz 2.5.2 and spring-context-support; spring-boot-starter-jdbc provides the DataSource and HikariCP. No driverDelegateClass is needed: the default StdJDBCDelegate stored and read the serialized JobDataMap correctly on H2, into the IMAGE columns of tables_h2.sql.

The URL decides everything

The same scenario was run against every H2 URL form that a Quartz service is likely to use. The "start 2" column is a second application context in the same JVM with initialize-schema=never, which is what a restart looks like to the database.

Table 4. H2 URL forms and what Quartz did with them (H2 2.4.240, default initialize-schema unless stated)
URL Start 1 Start 2 What it means

jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1

tables created, jobs fire

state found, counter 4 to 5

The recommended in-memory form. The database lives as long as the JVM, so several contexts (and test classes) can share it.

no spring.datasource.url (Boot-generated)

jdbc:h2:mem:<uuid>, 11 tables, jobs fire

a new context gets a new empty database

Fine for one context. Boot generates a unique name per context, so nothing survives a context restart.

jdbc:h2:mem:bookshelf (no DB_CLOSE_DELAY)

tables created, jobs fire

fails: Table "QRTZ_JOB_DETAILS" not found (this database is empty)

An in-memory H2 database is dropped when its last connection closes. Closing the first context closed the pool, so the data was gone.

jdbc:h2:file:<path>/bookshelf

fails at start: Table "QRTZ_JOB_DETAILS" not found (this database is empty)

(not reached)

embedded does not initialize file databases. Use always once, or a migration tool.

jdbc:h2:file:<path>/bookshelf with initialize-schema: always, continue-on-error: false

tables created, jobs fire

state found, counter 4 to 5 (started with never)

The first start works. A second start with always and continue-on-error: false fails, see below.

jdbc:h2:mem:bookshelf;MVCC=TRUE;DB_CLOSE_DELAY=-1

fails: Unsupported connection setting "MVCC" [90113-240]

(not reached)

The header of tables_h2.sql still tells you to use MVCC=TRUE. H2 2.x rejects the setting: its only engine is multi-version already.

The H2 web console is not needed for any of this and was not enabled; to look at the tables, query INFORMATION_SCHEMA.TABLES from a test, or open the file database with H2’s own tools while the application is stopped.

A file database and always: no DROP in the H2 script

The header of the H2 script and the documentation of the other engines suggest that always recreates the tables. For H2 that is not what the 2.5.2 script does: tables_h2.sql contains only CREATE TABLE and CREATE INDEX statements and no DROP. Measured on a file database:

  • With continue-on-error left at its default true, starting twice with always kept the data: a marker row inserted into QRTZ_CALENDARS after the first start was still there after the second, and the four jobs were still stored. Every CREATE TABLE of the second start failed with "already exists" and Boot ignored the errors.

  • With continue-on-error: false, the second start failed during bean creation with Table "QRTZ_CALENDARS" already exists, naming the statement of tables_h2.sql that failed.

So on H2 the combination "file database plus `always`" is harmless but only because the failures are swallowed. It is a sign that the schema should be owned by a migration tool; the Flyway and Liquibase sections below show both. The same is not true for HSQLDB (next section), where the script drops the tables first.

Locking, isolation and clustering on H2

Quartz serializes its critical sections with a row lock: it runs SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = ? AND LOCK_NAME = ? FOR UPDATE (the SELECT_FOR_LOCK constant of StdRowLockSemaphore) inside the scheduler’s transaction. Three facts were measured on H2:

  • Under Spring Boot the lock handler is StdRowLockSemaphore with useDBLocks=true, in clustered and in non-clustered mode.

  • The default isolation level of a pooled H2 connection is READ_COMMITTED (JDBC value 2).

  • The lock is real. With connection A holding the TRIGGER_ACCESS lock row, connection B asking for the same row waited and gave up inside the three-second window with Timeout trying to lock table "QRTZ_LOCKS", while B asking for the STATE_ACCESS row was served at once (row-level locking). B was also refused the same row when the connection of A was set to READ_COMMITTED, REPEATABLE_READ or SERIALIZABLE.

Clustering was tested in two ways. First, three Quartz schedulers with instanceId=AUTO and isClustered=true in one JVM on one in-memory database ran for 30 seconds: 32 executions of the cache refresh and 17 of the overdue-loan reminders, none executed twice, and the work was split between the nodes in the usual first-come way (one node ran every cache refresh, the overdue reminders went mostly to another). Second, a real two-process cluster: the H2 file database was opened with AUTO_SERVER=TRUE, the first JVM started the scheduler (initialize-schema: always), a second JVM attached to the same database 10 seconds later (never), and a third process read the state table:

Two JVMs sharing one H2 file database (AUTO_SERVER=TRUE), read by a third process after 25 seconds
host-a1791195562172 msSinceLastCheckin=1881 interval=2000
host-a1791195572112 msSinceLastCheckin=1572 interval=2000
triggers=4

Both nodes appear in QRTZ_SCHEDULER_STATE with a recent check-in and the four triggers are shared, so a two-node cluster on one machine works on H2 in server mode. That is useful for reproducing a cluster bug on a laptop, and for nothing else; the production story is on Clustering.

The configuration of the two-process run, as application.yml (the measured run passed the same keys as arguments)
spring:
  datasource:
    url: jdbc:h2:file:./data/bookshelf;AUTO_SERVER=TRUE
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: always        # the first node only; use never on the others
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.isClustered: "true"
      org.quartz.jobStore.clusterCheckinInterval: "2000"

HSQLDB

HSQLDB needs the same Maven setup as H2 with org.hsqldb:hsqldb instead (Boot manages 2.7.3), and the same in-memory URL rule: jdbc:hsqldb:mem:<name> is initialized by embedded, jdbc:hsqldb:file:<path> is not.

Configuration for HSQLDB in memory with the delegate that the script header names
spring:
  datasource:
    url: jdbc:hsqldb:mem:bookshelf
  quartz:
    job-store-type: jdbc
    jdbc:
      continue-on-error: false
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.HSQLDBDelegate

tables_hsqldb.sql says in its header that HSQLDBDelegate should be configured, and the delegate’s source shows why: it overrides the two methods that read a BLOB column so they use getBinaryStream instead of getBlob. The measured result is more relaxed than the header:

Table 5. Delegates on HSQLDB 2.7.3 (the Bookshelf scenario, including the restart)
Delegate Result Remark

StdJDBCDelegate (the default, nothing configured)

passed, counter 4 to 5

tables_hsqldb.sql declares BLOB columns; the default delegate reads them with getBlob and HSQLDB 2.7.3 accepted that.

HSQLDBDelegate

passed, counter 4 to 5

The delegate the script header and the Javadoc name. Configure it anyway: it is the documented pairing.

HSQLDBDelegate on H2 and on Derby (not needed there)

passed

The delegate only changes how binary data is read; it is harmless on engines that accept it.

A conclusion worth stating exactly: on HSQLDB 2.7.3 the delegate was not required to pass this scenario; the page recommends HSQLDBDelegate because the script and the Javadoc say so, not because the default failed. (On PostgreSQL the default does fail, which is why that page calls its delegate mandatory.)

HSQLDB differs from H2 in three ways that were measured or read from the sources:

  • The script drops first. tables_hsqldb.sql starts with eleven DROP TABLE …​ IF EXISTS statements. On a file database started twice with always, a marker row in QRTZ_CALENDARS was present after the first start and gone after the second: always on a persistent HSQLDB file destroys every trigger on every restart. On an in-memory database it is irrelevant.

  • No row locks under Spring. Spring’s LocalDataSourceJobStore (the store Boot uses when a DataSource is present) contains a special case for HSQLDB: if the product name contains hsql it calls setUseDBLocks(false) and installs a SimpleSemaphore. Measured: with HSQLDB the lock handler is SimpleSemaphore with useDBLocks=false, also when isClustered=true. Quartz therefore does not use FOR UPDATE on HSQLDB at all.

  • Table-level blocking when the lock is used. A manual probe of SELECT …​ FOR UPDATE on QRTZ_LOCKS made the second connection wait for the first one’s transaction even when it asked for a different lock row. HSQLDB 2.7.3 serializes writers per table here. Together with the previous point: nothing in HSQLDB under Spring Boot gives you the cross-node mutual exclusion that a cluster relies on, and the tests with two schedulers in one JVM on HSQLDB (10 executions of the cache refresh, 5 of the overdue reminders, none executed twice, 8 seconds) only show that single-process coordination works.

The default isolation level of a pooled HSQLDB connection is READ_COMMITTED.

Apache Derby

Derby needs two artifacts in 10.16, and forgetting the second is the usual first failure:

Maven dependencies for Derby (derbytools holds org.apache.derby.jdbc.EmbeddedDriver)
<dependency>
  <groupId>org.apache.derby</groupId>
  <artifactId>derby</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>org.apache.derby</groupId>
  <artifactId>derbytools</artifactId>
  <scope>runtime</scope>
</dependency>

Measured: without derbytools the start fails with java.lang.ClassNotFoundException: org.apache.derby.jdbc.EmbeddedDriver while Hikari is created, because Boot’s driver id for Derby names that class and it lives in derbytools-10.16.1.1.jar (the derby jar only has AutoloadedDriver). derbyshared is pulled in by derby.

Configuration for Derby in memory
spring:
  datasource:
    url: jdbc:derby:memory:bookshelf;create=true
  quartz:
    job-store-type: jdbc
    jdbc:
      continue-on-error: false

What was measured on Derby 10.16.1.1:

  • Delegate. tables_derby.sql needs no special delegate: with the default StdJDBCDelegate the whole scenario passed (counter 4 to 5, both with memory: and with a directory database). The HSQLDBDelegate also passed, which is not a recommendation. The script stores the boolean flags in varchar(5) columns, which is long enough for the text true or false (the default delegate passed).

  • The script is re-run on every start. Boot treats every Derby URL as embedded, so embedded runs tables_derby.sql against a directory database each time. The script has its drop table statements commented out. With the default continue-on-error: true the second start silently ignored the "already exists" errors and the data survived (marker row and four jobs still present); with continue-on-error: false the second start failed with ERROR X0Y32: Table/View 'QRTZ_JOB_DETAILS' already exists in Schema 'SA'. As with H2, the quiet success hides a script that should be owned by a migration tool.

  • Cloudscape. tables_derby_previous.sql and tables_cloudscape.sql name a CloudscapeDelegate in their headers. That class does not exist in 2.5.2; do not copy that line.

  • Deprecation. Derby support is deprecated in Spring Boot 4.1 and planned for removal in 4.3 (see above). New code should use H2 or HSQLDB.

The locking result for Derby is the most important finding of this page.

Derby row locking at the default isolation level
A: SELECT ... FOR UPDATE on QRTZ_LOCKS (TRIGGER_ACCESS), rs.next(), close, keep the transaction open
default isolation of a pooled Derby connection: READ_COMMITTED (2)
READ_COMMITTED:   B asking for the same row is served at once (0 ms)
REPEATABLE_READ:  B is still blocked after 3 s
SERIALIZABLE:     B is still blocked after 3 s

The probe copies what StdRowLockSemaphore.executeSQL does: it runs the query, reads one row and closes the result set and statement, then leaves the transaction open. On Derby at READ_COMMITTED the update lock of FOR UPDATE is released as soon as the cursor is closed, so the "lock" does not exclude another connection; at REPEATABLE_READ and SERIALIZABLE it is held until the transaction ends, like on H2. Quartz has a switch for this (org.quartz.jobStore.txIsolationLevelSerializable, which JobStoreSupport applies to the connections it creates; whether that changes the lock behaviour under Boot’s LocalDataSourceJobStore was not measured). Three Derby schedulers in one JVM ran for 30 seconds with and without it and executed nothing twice (34 and 33 cache refreshes), which shows that the scenario does not trigger the race, not that the race cannot happen. Treat Derby as single-node only.

Comparing the three engines

Table 6. Everything measured on the embedded engines, side by side
H2 2.4.240 HSQLDB 2.7.3 Derby 10.16.1.1

Boot embedded initializes

only jdbc:h2:mem

only jdbc:hsqldb:mem:

every Derby URL

Script and platform id

tables_h2.sql, h2

tables_hsqldb.sql, hsqldb

tables_derby.sql, derby

Delegate that passed

StdJDBCDelegate

StdJDBCDelegate and HSQLDBDelegate (script header names the latter)

StdJDBCDelegate

Script drops tables first

no

yes (DROP TABLE …​ IF EXISTS)

no (the drops are comments)

always on a persistent database

keeps data, errors ignored

destroys data on every start

keeps data, errors ignored

Lock handler under Boot

StdRowLockSemaphore

SimpleSemaphore (Spring special case)

StdRowLockSemaphore

Row lock excludes a second connection

yes, row level, times out

(not used by Quartz) table level when probed

only at REPEATABLE_READ and above

Default isolation of a pooled connection

READ_COMMITTED

READ_COMMITTED

READ_COMMITTED

Shareable between JVMs as configured here

yes with AUTO_SERVER=TRUE (two JVMs ran)

no

no

Needs a migration-tool module

no (Flyway core)

yes, flyway-database-hsqldb

yes, flyway-database-derby

Boot status

supported

supported

deprecated since 4.1.0, removal planned for 4.3.0

Schema management with Flyway and Liquibase

The embedded mode is convenient for a throw-away database and wrong for a database that has a lifetime. A file database, a shared development database and every production database should have the Quartz tables created by the same migration tool as the rest of the schema, with spring.quartz.jdbc.initialize-schema set to never. The generic page describes the idea; this section shows both tools on an embedded engine, because that is the cheapest place to try them. The wider tooling is on Schema Evolution; Choosing a Migration Tool compares Flyway, Liquibase and Mongock, and Liquibase Getting Started covers the tool itself.

Flyway

Flyway needs the script as a versioned migration. The 2.5.2 jar contains the H2 script, so the migration is a copy of it, with a name Flyway understands:

Creating the migration from the Quartz jar (extracting the script gives exactly the file that Boot would run)
mkdir -p src/main/resources/db/migration
unzip -p ~/.m2/repository/org/quartz-scheduler/quartz/2.5.2/quartz-2.5.2.jar \
  org/quartz/impl/jdbcjobstore/tables_h2.sql \
  > src/main/resources/db/migration/V1__quartz_tables.sql
application-flyway.yml: Flyway creates the tables, Quartz only uses them
spring:
  datasource:
    url: jdbc:h2:file:./target/bookshelf-flyway
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never      # Flyway owns the schema
  flyway:
    enabled: true
    locations: classpath:db/migration

Measured with spring-boot-starter-flyway: Flyway ran V1__quartz_tables.sql before the scheduler started, flyway_schema_history held the baseline row and version=1, description=quartz tables, success=true, the 11 tables existed, the Bookshelf jobs were stored, and a second application start was a no-op for Flyway and a restart for Quartz. The same migration also passed the complete Bookshelf scenario including the restart on an in-memory H2 database. Boot orders the scheduler after the database initializers, which is why no explicit dependency is needed.

Flyway 12 loads each database family as a separate module. Measured with only flyway-core on the classpath: HSQLDB failed with FlywayException: Unsupported Database: HSQL Database Engine 2.7 and Derby with Unsupported Database: Apache Derby 10.16. Adding org.flywaydb:flyway-database-hsqldb and org.flywaydb:flyway-database-derby (versions managed by Boot) fixed both; the migration for HSQLDB was tables_hsqldb.sql and for Derby tables_derby.sql, copied the same way. H2 needs no extra module.

Extra Flyway modules for the other embedded engines (Boot manages the versions)
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-hsqldb</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-derby</artifactId>
</dependency>

Liquibase

The Quartz 2.5.2 jar ships a Liquibase changelog, org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, with one change set (id="quartz-init", author="quartz") that creates all 11 tables with database-independent types (it only special-cases the BLOB type for PostgreSQL, where it uses BYTEA). Because it is on the classpath, a changelog of your own can simply include it:

src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
application-liquibase.yml: Liquibase creates the tables, Quartz only uses them
spring:
  datasource:
    url: jdbc:h2:file:./target/bookshelf-liquibase
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never      # Liquibase owns the schema
  liquibase:
    enabled: true
    change-log: classpath:db/changelog/db.changelog-master.yaml

Measured with spring-boot-starter-liquibase: the changelog table recorded quartz-init from org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, the 11 tables existed, the Bookshelf jobs were stored and, on an in-memory H2 database, the complete scenario (jobs fire, BLOB job data, restart) passed. If you prefer to own the DDL, use a sqlFile change that points at your copy of the engine’s script instead; the bundled changelog is the shorter route when the types it chooses suit you. Run only one migration tool per schema. With both starters on the classpath, switch off the one that has nothing to do with spring.flyway.enabled: false or spring.liquibase.enabled: false; the two runs above used only the starter of the tool being tested.

Do not combine a migration tool with the embedded initializer

With Flyway enabled and initialize-schema left at embedded on an in-memory H2 database, the application started normally, because Flyway ran first and the Quartz script’s "already exists" errors were ignored by the default continue-on-error: true. With continue-on-error: false the same configuration failed with Table "QRTZ_CALENDARS" already exists. Liquibase behaved like Flyway in the default configuration. The remedy is the one line initialize-schema: never in the profile that has a migration tool.

The dev and prod profile split

The most useful application of an embedded database is not a test, it is a profile: one application.yml with a YAML document per Spring profile, an embedded Quartz store (or no store) while developing, and PostgreSQL in the deployed environments. The Java code does not change; only the properties do. Spring’s profile-specific documents use spring.config.activate.on-profile, one --- separator per document.

Three profile documents of one application.yml, drawn as three boxes fed by a source box. The dev profile uses an embedded H2 database in memory with the URL jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1 and initialize-schema embedded, which creates 11 tables from tables_h2.sql with the default StdJDBCDelegate; it serves laptops and unit tests without Docker. The dev-memory profile uses the RAMJobStore through job-store-type memory, so nothing is persisted, the misfireThreshold is 5000 ms and every restart forgets all triggers; it gives fast feedback on job logic without exercising persistence, misfire recovery or clustering. The prod profile uses PostgreSQL with url, user and password from the environment, a Flyway V1 migration of tables_postgres.sql, initialize-schema never, the PostgreSQLDelegate, isClustered true and instanceId AUTO; it survives restarts and is shared by every node of the cluster
application.yml with a shared document and three profile documents (this exact file was run)
spring:
  application:
    name: bookshelf-service
  quartz:
    scheduler-name: BookshelfScheduler
    wait-for-jobs-to-complete-on-shutdown: true

---
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    url: jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1
    username: sa
    password: ""
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: embedded        # default: creates tables_h2.sql in the in-memory database
  flyway:
    enabled: false
  liquibase:
    enabled: false

---
spring:
  config:
    activate:
      on-profile: dev-memory
  quartz:
    job-store-type: memory               # RAMJobStore: nothing is persisted
  flyway:
    enabled: false
  liquibase:
    enabled: false

---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    url: ${DB_URL}
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  flyway:
    enabled: true
    locations: classpath:db/migration-pg
  liquibase:
    enabled: false
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never           # Flyway owns the schema
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
      org.quartz.jobStore.isClustered: "true"

The flyway.enabled and liquibase.enabled lines are there because the test project had both starters on the classpath at once; a real service has one of them (or neither, if the dev profile is the only one that uses an embedded database) and can delete the lines it does not need. The db/migration-pg folder contains the PostgreSQL script as V1__quartz_tables.sql (a copy of tables_postgres.sql from the Quartz jar, as in the Flyway section). The three secrets are placeholders resolved from the environment; nothing real is in the file.

Each profile was started and observed:

Table 7. The three profiles run on Quartz 2.5.2 and Boot 4.1.1
Profile What was measured Reading

dev

scheduler name BookshelfScheduler, LocalDataSourceJobStore, datasource jdbc:h2:mem:bookshelf, 11 QRTZ_ tables, the overdue-loan reminders fired

The embedded initializer created the schema because the URL is jdbc:h2:mem.

dev-memory

RAMJobStore, supportsPersistence=false, misfireThreshold=5000, the reminders fired, no QRTZ_ tables

A DataSource bean still exists because H2 is on the classpath and Boot creates an embedded one, but Quartz does not use it.

prod

PostgreSQL 17 through Testcontainers, LocalDataSourceJobStore, delegate PostgreSQLDelegate, clustered=true, instance id <host><millis>, Flyway version=1, description=quartz tables, success=true, the reminders fired

The environment variables fed the datasource, Flyway created the tables and Quartz stored its state in PostgreSQL.

The dev-memory profile is the fastest to start, but it is the weakest test of your jobs: it uses a different job store, with a different default misfireThreshold (5 seconds instead of the JDBC store’s 60 seconds) and no serialization of job data. Use it for quick local iteration, and run the dev profile (or the tests below) before you push.

The Bookshelf schedule that all three profiles share is the same set of beans, taken unchanged from the test project (the bookshelf.fast flag only shortens the intervals in tests):

The cache refresh and the overdue-loan reminders as Quartz beans (excerpt of BookshelfSchedule)
@Configuration(proxyBeanMethods = false)
public class BookshelfSchedule {

    @Bean JobDetail cacheRefreshJob() {
        return JobBuilder.newJob(CacheRefreshJob.class).withIdentity("cacheRefresh", "bookshelf")
                .withDescription("Refresh the catalogue cache").storeDurably().build();
    }
    @Bean Trigger cacheRefreshTrigger(JobDetail cacheRefreshJob, @Value("${bookshelf.fast:false}") boolean fast) {
        return TriggerBuilder.newTrigger().forJob(cacheRefreshJob).withIdentity("cacheRefreshTrigger", "bookshelf")
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withIntervalInSeconds(fast ? 1 : 300).repeatForever()
                        .withMisfireHandlingInstructionNextWithRemainingCount()).build();
    }

    @Bean JobDetail overdueLoanRemindersJob() {
        return JobBuilder.newJob(OverdueLoanRemindersJob.class).withIdentity("overdueLoanReminders", "bookshelf")
                .usingJobData("runs", 0).storeDurably().build();
    }
    @Bean Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob, @Value("${bookshelf.fast:false}") boolean fast) {
        return TriggerBuilder.newTrigger().forJob(overdueLoanRemindersJob).withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule(fast ? "0/2 * * * * ?" : "0 0 2 * * ?")
                        .withMisfireHandlingInstructionFireAndProceed()).build();
    }
}

The cron expression here has no time zone, so it uses the JVM’s default; the nightly 02:00 Europe/Madrid reminders of the full scenario set the zone explicitly, which the Cron Expressions and Misfires pages explain.

A test that needs no container

The dev profile is also what makes a fast integration test possible. The test below activates it, so the real application.yml supplies the H2 URL, and it asserts on the actual QRTZ_ tables:

EmbeddedQuartzTest.java: the persistent code path without Docker (compiled and run, 1 test, 0 failures)
@SpringBootTest
@ActiveProfiles("dev")
class EmbeddedQuartzTest {

    @Autowired Scheduler scheduler;
    @Autowired JdbcTemplate jdbc;

    @Test
    void bookshelfJobsAreStoredInTheEmbeddedDatabase() throws Exception {
        // Boot created the 11 QRTZ_ tables from tables_h2.sql before the scheduler started
        Integer tables = jdbc.queryForObject(
                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE 'QRTZ\\_%'", Integer.class);
        assertThat(tables).isEqualTo(11);

        // the JobDetail and Trigger beans were persisted by the scheduler
        assertThat(scheduler.getJobDetail(JobKey.jobKey("overdueLoanReminders", "bookshelf"))).isNotNull();
        assertThat(scheduler.getTriggerState(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf")))
                .isEqualTo(TriggerState.NORMAL);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(*) FROM QRTZ_TRIGGERS WHERE TRIGGER_NAME = ? AND TRIGGER_STATE IN ('WAITING', 'ACQUIRED')",
                Integer.class, "overdueLoanRemindersTrigger")).isEqualTo(1);
    }
}

The imports are org.springframework.boot.test.context.SpringBootTest, org.springframework.test.context.ActiveProfiles, org.quartz.Scheduler, org.quartz.JobKey, org.quartz.TriggerKey, org.quartz.Trigger.TriggerState, org.springframework.jdbc.core.JdbcTemplate, org.springframework.beans.factory.annotation.Autowired and the static org.assertj.core.api.Assertions.assertThat. The Testcontainers version of the same test for each production database is on that database’s page, and the testing patterns (waiting for a job to fire, replacing the clock, pausing the scheduler) are on Testing; the Testcontainers setup in general is on Spring Boot Integration Tests with Testcontainers. The profile mechanism itself is on Configuration and Profiles.

The other scripted SQL engines

The Quartz 2.5.2 jar ships a DDL script for several engines beyond the ones that have their own page. None of the engines in this section was run for this page: no container image or licence was available, and the facts below were taken from the file list of the 2.5.2 jar, from the headers of the scripts and from the delegate source code. Treat each row as a starting point to verify in your own environment, in the same way as the measured engines were verified.

Table 8. Scripted engines that were not run (from the Quartz 2.5.2 jar and sources)
Engine Script(s) in the jar Delegate What the sources say

IBM DB2

tables_db2.sql (DB2 7.1 era), tables_db2_v72.sql, tables_db2_v8.sql, tables_db2_v95.sql

DB2v6Delegate, DB2v7Delegate, DB2v8Delegate (all extend StdJDBCDelegate)

Spring Boot maps the DB2 platform to tables_db2_v95.sql. That script declares the boolean flags as integer, which is what DB2v8Delegate writes (setInt), so the matching pairing is the v95 script with DB2v8Delegate. DB2v7Delegate writes booleans as "1" and "0" into varchar(1) and binds job data with setObject(…​, Types.BLOB); DB2v6Delegate only replaces count(name) with count(*). The blob(2000) size in the v72, v8 and v95 scripts limits JOB_DATA: enlarge it if jobs carry big data maps.

Sybase ASE

tables_sybase.sql (written for ASE 12.5)

SybaseDelegate

The script begins with use your_db_name_here and uses go as its separator, exactly the two things that stop Spring’s script runner on SQL Server (see the SQL Server page): edit the database name and replace go with ;, or run it with the vendor’s own client. SybaseDelegate reads binary data with getBinaryStream and writes it with setBytes.

CUBRID

tables_cubrid.sql (CUBRID 8.4.1 and later)

CUBRIDDelegate

Uses BIT(1) flag columns and BLOB data. The delegate reads and writes blobs explicitly (its source cites a CUBRID issue about zero-byte blobs).

GaussDB

tables_gauss_default_compatibility.sql and tables_gauss_m_compatibility.sql

GaussDBDelegate for the default-compatibility script; StdJDBCDelegate for the M-compatibility script

The default-compatibility script and delegate are derived from the PostgreSQL ones (bytea read with getBytes); the header of the M-compatibility script names StdJDBCDelegate, and it uses BOOL and BLOB columns.

InterSystems Cache

none of its own

CacheDelegate

The Javadoc says it works with the Oracle table creation scripts; the delegate binds and frees blobs explicitly. For Oracle’s current script choice see the Oracle page.

Several older scripts are also in the jar. They are historical, and some headers were never updated:

Table 9. Other scripts in the 2.5.2 jar
Script Delegate named by the script or the jar Notes

tables_informix.sql

StdJDBCDelegate (its header)

Informix limits table names to 18 characters, so the script creates tables named qjob_details, qtriggers and so on: set org.quartz.jobStore.tablePrefix=q.

tables_firebird.sql

none named

Drops first without IF EXISTS; no delegate is mentioned, so start from StdJDBCDelegate.

tables_pointbase.sql

PointbaseDelegate exists in the jar

The header advises enlarging the blob columns before creating the tables.

tables_sapdb.sql, tables_solid.sql

StdJDBCDelegate (SapDB header); none named for Solid

Both create the eleven tables with varchar(1) flags.

tables_cloudscape.sql, tables_derby_previous.sql

header names a CloudscapeDelegate that does not exist

Superseded by tables_derby.sql for Derby.

tables_hsqldb_old.sql

HSQLDBDelegate (its header)

Older HSQLDB layout; the header mentions changing the OTHER data type to BINARY on some versions. Boot picks tables_hsqldb.sql, not this one.

The jar also contains WebLogicDelegate and oracle.weblogic.WebLogicOracleDelegate for application-server DataSources; they are covered by the generic page and not by a script of their own. All the scripts above create the same 11 tables; only the types, the drops and the separators differ.

Pitfalls

Table 10. Embedded and other-SQL pitfalls, most of them measured
Pitfall What happens What to do

initialize-schema left at embedded with a file or server URL

Nothing is created; the application fails during start with Table "QRTZ_JOB_DETAILS" not found (this database is empty) (H2) or user lacks privilege or object not found: QRTZ_JOB_DETAILS (HSQLDB).

Use a jdbc:h2:mem or jdbc:hsqldb:mem: URL in the profile that relies on embedded, or create the schema with a migration tool and set never.

H2 in-memory URL without DB_CLOSE_DELAY=-1

The first context’s tables disappear when its pool closes; a second context finds an empty database.

Add DB_CLOSE_DELAY=-1 to a named in-memory URL; remember the database then lives until the JVM ends.

Copying MVCC=TRUE from the header of tables_h2.sql

H2 2.4.240 rejects it: Unsupported connection setting "MVCC" [90113-240].

Delete the setting. Multi-version concurrency is how H2 2.x works.

always on a persistent HSQLDB file database

tables_hsqldb.sql drops every table first, so each restart wipes all jobs and triggers (a marker row was gone after the second start).

Use always only for in-memory databases, or create the schema once with a migration tool and use never.

Silent script errors (continue-on-error defaults to true)

Repeated runs on H2 or Derby fail with "already exists" and nobody notices; with false the second start fails.

Set continue-on-error: false in tests, and move persistent schemas to Flyway or Liquibase.

Derby without derbytools

java.lang.ClassNotFoundException: org.apache.derby.jdbc.EmbeddedDriver while the pool is created.

Add both derby and derbytools, and plan to leave Derby: it is deprecated in Boot 4.1 for removal in 4.3.

Relying on Quartz’s row lock on Derby at the default isolation

The FOR UPDATE lock is released when the cursor closes at READ_COMMITTED, so a second connection is not blocked.

Do not cluster on Derby. If you must, use txIsolationLevelSerializable and test the exclusion yourself.

Expecting FOR UPDATE row locks on HSQLDB under Spring Boot

Spring’s LocalDataSourceJobStore installs a SimpleSemaphore for HSQLDB, even when isClustered=true; there is no cross-process lock.

Use HSQLDB for single-process tests only.

Flyway starter with HSQLDB or Derby but only flyway-core

FlywayException: Unsupported Database: HSQL Database Engine 2.7 (or Apache Derby 10.16).

Add flyway-database-hsqldb or flyway-database-derby.

A migration tool and embedded together

Works while continue-on-error is true (errors ignored); fails with Table "QRTZ_CALENDARS" already exists when it is false.

Set initialize-schema: never wherever Flyway or Liquibase is enabled.

A dev-memory profile as the only local run

RAMJobStore accepts a non-serializable JobDataMap value; the same call on a JDBC store throws JobPersistenceException caused by NotSerializableException: Unable to serialize JobDataMap for insertion into database because the value of property 'mailbox' is not serializable.

Run the dev (JDBC) profile or a test with an embedded database before merging, and put only Serializable values (or primitives) into job data.

Removing H2 from the classpath of a Boot-generated database

Boot silently switches to the next embedded engine (H2, then Derby, then HSQLDB), with a different script and different locking.

Set spring.datasource.url explicitly in every profile that uses an embedded database.

Treating an embedded database as a cluster store

Two schedulers in one JVM coordinate, a second JVM cannot attach to an in-memory database, and two of the three engines do not give Quartz an exclusive lock.

Use PostgreSQL or another server database; see Production and Best Practices.

What changed

Table 11. Changes that affect this page
Change Effect

Spring Boot 4.0 split Quartz into the spring-boot-quartz module

The classes live in org.springframework.boot.quartz.autoconfigure; on 3.5.16 they are in org.springframework.boot.autoconfigure.quartz. The property names (spring.quartz.jdbc.*) are unchanged.

Spring Boot 4.1.0 deprecated Derby support

EmbeddedDatabaseConnection.DERBY and DatabaseDriver.DERBY carry @Deprecated(since = "4.1.0", forRemoval = true) with removal planned for 4.3.0. Derby still ran on 4.1.1.

H2 1.4.x to 2.x

The MVCC connection setting is gone (rejected on 2.4.240); the script header was not updated. Boot 3.5.16 manages H2 2.3.232 and Boot 4.1.1 manages 2.4.240.

Flyway 10 and later

Database support moved into per-family modules (flyway-database-hsqldb, flyway-database-derby, flyway-database-postgresql); Boot 4.1.1 manages Flyway 12.4.0, Boot 3.5.16 manages 11.7.2.

Quartz 2.5.2

The jar contains liquibase.quartz.init.xml next to the SQL scripts, and a script per engine as listed above, including tables_oracle23.sql and the two GaussDB scripts. The DB2v6Delegate, DB2v7Delegate and DB2v8Delegate classes are in the jar; the CloudscapeDelegate that two script headers name is not.

Where to go next

The generic concepts and the database support matrix are on Job Stores and Persistence. The production databases are PostgreSQL, MySQL and MariaDB, Oracle and SQL Server. The Spring Boot properties are collected on Spring Boot Configuration, and the changes between versions on Migration and What’s Changed. The transaction and locking background that explains why a database lock matters is on Transaction Isolation and Locking, and the JPA side of a Spring Boot service on Spring Data JPA. The SQL language itself is on SQL.