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.
| Use | Verdict | Why |
|---|---|---|
Developer laptop, run the service without a database container |
Good |
The jobs, the triggers and the |
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).
| Component | Version | Notes |
|---|---|---|
Quartz |
2.5.2 |
Both Boot 4.1.1 and 3.5.16 manage it. |
H2 |
2.4.240 |
|
HSQLDB |
2.7.3 |
|
Apache Derby |
10.16.1.1 |
|
PostgreSQL |
|
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.
Source: Spring Boot: Quartz Scheduler and the Spring Boot source: EmbeddedDatabaseConnection (v4.1.1)
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
embeddedmode,jdbc:h2:mem:…,jdbc:hsqldb:mem:…,jdbc:derby:memory:…andjdbc:derby:<directory>all got the 11 tables, whilejdbc:h2:file:…andjdbc:hsqldb:file:…got nothing and the application failed during start withTable "QRTZ_JOB_DETAILS" not found (this database is empty)(H2) anduser 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.urlis 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:
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]
| Engine | Platform id | Script resource Boot executes | Tables created |
|---|---|---|---|
H2 |
|
|
11 |
HSQLDB |
|
|
11 |
Derby |
|
|
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.
<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>
Source: Spring Boot: Quartz Scheduler
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.
| URL | Start 1 | Start 2 | What it means |
|---|---|---|---|
|
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 |
|
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. |
|
tables created, jobs fire |
fails: |
An in-memory H2 database is dropped when its last connection closes. Closing the first context closed the pool, so the data was gone. |
|
fails at start: |
(not reached) |
|
|
tables created, jobs fire |
state found, counter 4 to 5 (started with |
The first start works. A second start with |
|
fails: |
(not reached) |
The header of |
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-errorleft at its defaulttrue, starting twice withalwayskept the data: a marker row inserted intoQRTZ_CALENDARSafter the first start was still there after the second, and the four jobs were still stored. EveryCREATE TABLEof the second start failed with "already exists" and Boot ignored the errors. -
With
continue-on-error: false, the second start failed during bean creation withTable "QRTZ_CALENDARS" already exists, naming the statement oftables_h2.sqlthat 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
StdRowLockSemaphorewithuseDBLocks=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_ACCESSlock row, connection B asking for the same row waited and gave up inside the three-second window withTimeout trying to lock table "QRTZ_LOCKS", while B asking for theSTATE_ACCESSrow was served at once (row-level locking). B was also refused the same row when the connection of A was set toREAD_COMMITTED,REPEATABLE_READorSERIALIZABLE.
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:
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.
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.
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
Source: Quartz Javadoc: 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:
| Delegate | Result | Remark |
|---|---|---|
|
passed, counter 4 to 5 |
|
|
passed, counter 4 to 5 |
The delegate the script header and the Javadoc name. Configure it anyway: it is the documented pairing. |
|
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.sqlstarts with elevenDROP TABLE … IF EXISTSstatements. On a file database started twice withalways, a marker row inQRTZ_CALENDARSwas present after the first start and gone after the second:alwayson 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 containshsqlit callssetUseDBLocks(false)and installs aSimpleSemaphore. Measured: with HSQLDB the lock handler isSimpleSemaphorewithuseDBLocks=false, also whenisClustered=true. Quartz therefore does not useFOR UPDATEon HSQLDB at all. -
Table-level blocking when the lock is used. A manual probe of
SELECT … FOR UPDATEonQRTZ_LOCKSmade 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:
<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.
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.sqlneeds no special delegate: with the defaultStdJDBCDelegatethe whole scenario passed (counter 4 to 5, both withmemory:and with a directory database). TheHSQLDBDelegatealso passed, which is not a recommendation. The script stores the boolean flags invarchar(5)columns, which is long enough for the texttrueorfalse(the default delegate passed). -
The script is re-run on every start. Boot treats every Derby URL as embedded, so
embeddedrunstables_derby.sqlagainst a directory database each time. The script has itsdrop tablestatements commented out. With the defaultcontinue-on-error: truethe second start silently ignored the "already exists" errors and the data survived (marker row and four jobs still present); withcontinue-on-error: falsethe second start failed withERROR 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.sqlandtables_cloudscape.sqlname aCloudscapeDelegatein 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.
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
| H2 2.4.240 | HSQLDB 2.7.3 | Derby 10.16.1.1 | |
|---|---|---|---|
Boot |
only |
only |
every Derby URL |
Script and platform id |
|
|
|
Delegate that passed |
|
|
|
Script drops tables first |
no |
yes ( |
no (the drops are comments) |
|
keeps data, errors ignored |
destroys data on every start |
keeps data, errors ignored |
Lock handler under Boot |
|
|
|
Row lock excludes a second connection |
yes, row level, times out |
(not used by Quartz) table level when probed |
only at |
Default isolation of a pooled connection |
|
|
|
Shareable between JVMs as configured here |
yes with |
no |
no |
Needs a migration-tool module |
no (Flyway core) |
yes, |
yes, |
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:
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
Source: Flyway documentation
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.
<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>
Source: Flyway documentation
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:
databaseChangeLog:
- include:
file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
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.
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:
| Profile | What was measured | Reading |
|---|---|---|
|
scheduler name |
The embedded initializer created the schema because the URL is |
|
|
A DataSource bean still exists because H2 is on the classpath and Boot creates an embedded one, but Quartz does not use it. |
|
PostgreSQL 17 through Testcontainers, |
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):
@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:
@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.
| Engine | Script(s) in the jar | Delegate | What the sources say |
|---|---|---|---|
IBM DB2 |
|
|
Spring Boot maps the DB2 platform to |
Sybase ASE |
|
|
The script begins with |
CUBRID |
|
|
Uses |
GaussDB |
|
|
The default-compatibility script and delegate are derived from the PostgreSQL ones ( |
InterSystems Cache |
none of its own |
|
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:
| Script | Delegate named by the script or the jar | Notes |
|---|---|---|
|
|
Informix limits table names to 18 characters, so the script creates tables named |
|
none named |
Drops first without |
|
|
The header advises enlarging the |
|
|
Both create the eleven tables with |
|
header names a |
Superseded by |
|
|
Older HSQLDB layout; the header mentions changing the |
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
| Pitfall | What happens | What to do |
|---|---|---|
|
Nothing is created; the application fails during start with |
Use a |
H2 in-memory URL without |
The first context’s tables disappear when its pool closes; a second context finds an empty database. |
Add |
Copying |
H2 2.4.240 rejects it: |
Delete the setting. Multi-version concurrency is how H2 2.x works. |
|
|
Use |
Silent script errors ( |
Repeated runs on H2 or Derby fail with "already exists" and nobody notices; with |
Set |
Derby without |
|
Add both |
Relying on Quartz’s row lock on Derby at the default isolation |
The |
Do not cluster on Derby. If you must, use |
Expecting |
Spring’s |
Use HSQLDB for single-process tests only. |
Flyway starter with HSQLDB or Derby but only |
|
Add |
A migration tool and |
Works while |
Set |
A |
|
Run the |
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 |
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
| Change | Effect |
|---|---|
Spring Boot 4.0 split Quartz into the |
The classes live in |
Spring Boot 4.1.0 deprecated Derby support |
|
H2 1.4.x to 2.x |
The |
Flyway 10 and later |
Database support moved into per-family modules ( |
Quartz 2.5.2 |
The jar contains |
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.