Quartz on MySQL and MariaDB

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.

MySQL and MariaDB are the databases most Spring Boot services start with, and both are supported by Quartz’s official JDBC job store: no special delegate is needed and a single DDL script serves both servers. That simplicity hides a handful of details that decide whether the store is correct under load or only appears to work in a demo: which of the two MySQL scripts in the Quartz jar to run (one of them predates InnoDB’s role as the default engine and must not be used), what isolation level Quartz really runs under, why a JobDataMap larger than 64 KB fails or, worse, is silently truncated, and what happens to table names when the server runs with a different lower_case_table_names. This page covers them in that order, 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 on an Apple Silicon (arm64) Mac, with Docker 29.8.2 and Testcontainers 2.0.5. The servers were the images mysql:9, which reported MySQL Community Server 9.7.2, and mariadb:11, which reported MariaDB 11.8.9 (both images are published for arm64 and ran natively, without emulation). The drivers were the ones Spring Boot 4.1.1 manages: MySQL Connector/J 9.7.0 and MariaDB Connector/J 3.5.10. The migration tools were Flyway 12.4.0 (module flyway-mysql) and Liquibase 5.0.3. 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 managed driver and migration-tool versions are older. Other server versions (MySQL 8.4 LTS, MariaDB 10.x, Percona, Amazon Aurora, Google Cloud SQL) were not run; statements about them are labelled as such.

Status and dependencies

Status: official. The JDBC job store, the DDL scripts and the delegates are part of Quartz itself. Nothing on this page is community code. Quartz ships one delegate that fits both servers, StdJDBCDelegate, which is also what Quartz uses when no delegate is configured, and one DDL script for MySQL and MariaDB together.

pom.xml: Quartz on MySQL or MariaDB with Flyway, Boot-managed versions
<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>

    <!-- choose the driver that matches your JDBC URL; either one worked against either server -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</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-mysql</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-mysql</artifactId>
        <version>2.0.5</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-mariadb</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. Keep only the driver you use; both are listed so the page can show both. A project that uses MariaDB needs mariadb-java-client only, and flyway-mysql also covers MariaDB (see Flyway). The Testcontainers artifact names changed in 2.x: testcontainers-mysql and testcontainers-mariadb, classes org.testcontainers.mysql.MySQLContainer and org.testcontainers.mariadb.MariaDBContainer.

Two scripts in the jar, one to use

The Quartz 2.5.2 jar contains two MySQL scripts in org/quartz/impl/jdbcjobstore/: tables_mysql_innodb.sql and tables_mysql.sql. There is no MariaDB script. The names suggest that the second one is for MyISAM, the engine MySQL used before 5.5, and its history agrees: the header of the InnoDB script says it is the original "modified to use INNODB instead of MYISAM". The two scripts were diffed and then each was run on both servers.

Table 1. The two scripts compared, with what each produced on mysql:9 and mariadb:11
Aspect tables_mysql_innodb.sql tables_mysql.sql (legacy)

ENGINE clause

ENGINE=InnoDB on all 11 tables

none: the table gets the server’s default_storage_engine. On both test servers that is InnoDB, and information_schema reported InnoDB for all 11 tables, so on a default server it also runs on InnoDB. On a server configured with another default it would not.

Name columns (JOB_NAME, TRIGGER_NAME, JOB_GROUP, TRIGGER_GROUP, INSTANCE_NAME, calendar name)

VARCHAR(190); SCHED_NAME is VARCHAR(120)

VARCHAR(200); SCHED_NAME is VARCHAR(120)

Secondary indexes

20 CREATE INDEX statements and one inline INDEX on QRTZ_BLOB_TRIGGERS; 21 secondary indexes measured after the Bookshelf run

none are declared. The one index measured is the implicit index MySQL creates for a FOREIGN KEY clause. Trigger acquisition, misfire scans and the QRTZ_FIRED_TRIGGERS lookups therefore scan tables.

Foreign keys

declared, and enforced by InnoDB

declared; enforced only when the table is InnoDB (a MyISAM table accepts the clause and ignores it)

Result of the Bookshelf run (four jobs, a dynamic job with a serialised JobDataMap, a restart with initialize-schema=never)

passed on both servers

passed on both servers, on InnoDB, without indexes

Source: tables_mysql_innodb.sql (v2.5.2) and tables_mysql.sql (v2.5.2). The columns are from running both scripts and reading information_schema.

The honest summary is that the legacy script is not "the MyISAM script" any more: on a modern server it creates InnoDB tables, but without the indexes and with the wider columns, and it is the wrong choice for a different reason than the name suggests. To see what MyISAM would really do, the legacy script was forced onto it by appending ENGINE=MyISAM to every CREATE TABLE, and three things were measured:

  • On a database with the default utf8mb4 character set the script does not even run: Specified key was too long; max key length is 1000 bytes on MySQL and the same message on MariaDB. MyISAM limits an index key to 1000 bytes and the primary key of QRTZ_JOB_DETAILS is three name columns of up to 520 characters, which is 2080 bytes in utf8mb4. (The InnoDB script’s VARCHAR(190) columns total 2000 bytes, which InnoDB accepts; see Names, utf8mb4 and key lengths.)

  • With DEFAULT CHARSET=latin1 added the script runs, and the Bookshelf run and a restart passed on both servers.

  • The decisive difference is locking. Quartz locks a row of QRTZ_LOCKS with SELECT …​ FOR UPDATE. On InnoDB a second connection asking for the same row waited and failed after the 2000 ms lock wait that the test set, with error 1205 (Lock wait timeout exceeded). On MyISAM the second connection got the row in 5 ms, on both servers: MyISAM has table locks only, FOR UPDATE does not hold anything, and the mutual exclusion that Quartz’s cluster protocol relies on is gone (details in Transactions and locking). A two-node clustered run on the MyISAM tables showed no duplicate execution in 12 seconds. That is not evidence that it is safe, only that a short, light run does not hit the window; the lock demonstration is the evidence.

Spring Boot chooses the script from the JDBC URL. QuartzDataSourceScriptDatabaseInitializer maps the platform MYSQL and the platform MARIADB to the same name, mysql_innodb, so tables_mysql_innodb.sql is what runs for both (source: QuartzDataSourceScriptDatabaseInitializer, which maps DB2 to db2_v95, MySQL and MariaDB to mysql_innodb and PostgreSQL to postgres). The measurement agrees: the databases created through jdbc:mysql URLs and through jdbc:mariadb URLs, against both servers, all had the same 21 secondary indexes and VARCHAR(190) columns.

From the JDBC URL to the Quartz script. A jdbc:mysql URL with MySQL Connector/J 9.7.0 and a jdbc:mariadb URL with MariaDB Connector/J 3.5.10 are detected by Spring Boot as the platforms MYSQL and MARIADB, which are both mapped to mysql_innodb, that is tables_mysql_innodb.sql: ENGINE=InnoDB on all 11 tables, VARCHAR(190) name columns, 21 secondary indexes. The legacy tables_mysql.sql has no ENGINE clause, VARCHAR(200) names and no declared indexes, is never selected by Boot, and is only used if someone sets spring.quartz.jdbc.schema. A box lists what does not change between the servers: StdJDBCDelegate, SELECT FOR UPDATE row locks, the server-default REPEATABLE-READ isolation, 65,535-byte BLOB columns, and that all four driver and server pairs ran the Bookshelf jobs

The delegate

There is no MySQL delegate in Quartz 2.5.2. The delegates in the jar are StdJDBCDelegate, PostgreSQLDelegate, MSSQLDelegate, HSQLDBDelegate, DB2v6Delegate, DB2v7Delegate, DB2v8Delegate, SybaseDelegate, PointbaseDelegate, CUBRIDDelegate, GaussDBDelegate, CacheDelegate, WebLogicDelegate, and the two in the oracle sub-package. MySQL and MariaDB use StdJDBCDelegate, as the header of the InnoDB script says. It needs no property: in the run the scheduler reported driverDelegateClass=null, and Quartz then uses StdJDBCDelegate. The contrast with the other engines is worth remembering, because the wrong delegate is the most common failure on them and cannot happen here: PostgreSQL needs PostgreSQLDelegate because of its bytea column type, and SQL Server needs MSSQLDelegate because of its lock statement. Setting the property explicitly is harmless and makes the choice visible in review, and the tests below do so.

application.yml

The production-shaped configuration keeps credentials in the environment, lets a migration tool own the schema, and sets the cluster properties. The keys were run with the container’s URL and credentials in place of the placeholders.

application.yml: Quartz on MySQL or MariaDB, schema owned by Flyway
spring:
  datasource:
    url: ${DB_URL}                  # jdbc:mysql://db.example.internal:3306/bookshelf or jdbc:mariadb://...
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 13         # at least the Quartz thread count plus 3
  flyway:
    enabled: true
    locations: classpath:db/migration/mysql
  quartz:
    job-store-type: jdbc
    scheduler-name: BookshelfScheduler
    jdbc:
      initialize-schema: never      # Flyway owns the schema; "always" would drop and recreate the tables on every start
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
      org.quartz.jobStore.isClustered: "true"
      org.quartz.jobStore.clusterCheckinInterval: "7500"
      org.quartz.threadPool.threadCount: "10"

Source: Spring Boot: Quartz Scheduler. clusterCheckinInterval is shown at its default; the tests used 2000 ms to make failover quicker.

Two rules apply to this file. With initialize-schema: always (used throughout the tests as a development convenience) Boot runs the script on every start, and the script begins with DROP TABLE IF EXISTS statements, so every stored job and trigger is deleted at each restart. Boot’s default, embedded, creates the schema only on an embedded database, so on MySQL or MariaDB nothing is created and the application must find its tables from a migration tool. The pool size follows the generic rule on the Job Stores & Persistence page: Quartz’s worker threads plus three connections.

Flyway

The migration is the InnoDB script without its DROP TABLE lines. They are harmless on the empty database that a first migration runs against, but a migration that contains DROP TABLE statements is a trap for anyone who copies it as a template, so the page removes them.

src/main/resources/db/migration/mysql/V1__quartz_tables.sql: produced by one command
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql \
  | grep -v '^DROP TABLE' > src/main/resources/db/migration/mysql/V1__quartz_tables.sql

Source: Spring Boot: Database Initialization. The result is 168 lines and was run through Flyway 12.4.0 on both servers: flyway_schema_history recorded version 1, description quartz tables, success=true, and the Bookshelf run and a restart with initialize-schema=never passed on MySQL 9.7.2 and on MariaDB 11.8.9. The file keeps the script’s # comment header, including an apostrophe, and Flyway’s MySQL parser handled it.

What Flyway printed is worth knowing before it frightens someone in a CI log. On MySQL 9.7 it warned Using MySQL 9.7 which is newer than the version Flyway has been verified with. The latest verified version of MySQL is 9.4., and on MariaDB 11.8 it warned Using MariaDB 11.8 which is newer than the version Flyway has been verified with. The latest verified version of MariaDB is 11.7. Both are warnings, not failures. On MySQL, the migration also produced MySQL’s own warning Integer display width is deprecated and will be removed in a future release (error code 1681), once for each BIGINT(13)-style column: the script still uses the display widths, and the server accepts them. The same migration tool and the same file work for both servers because Flyway’s MariaDB support lives in the flyway-mysql module (Spring Boot 4.1.1’s dependency management lists flyway-mysql and no MariaDB module). Do not edit an applied migration. A later change, for instance the LONGBLOB change in BLOB handling, is a new V2__ file.

The general Flyway workflow and the choice between the tools are on Choosing a Migration Tool.

Liquibase

Liquibase can run the same script through sqlFile, and the Quartz jar also contains its own Liquibase changelog. Three variants were run on both servers, each against an empty database with initialize-schema=never, and all three passed: the changeset was recorded in DATABASECHANGELOG as EXECUTED, the Bookshelf jobs fired and the state survived a restart.

src/main/resources/db/changelog/changelog-sqlfile-copy.yaml: the migration file as a Liquibase changeset
databaseChangeLog:
  - changeSet:
      id: quartz-tables-1
      author: bookshelf
      changes:
        - sqlFile:
            path: db/changelog/quartz-tables-mysql-innodb.sql
            splitStatements: true
            endDelimiter: ;

Source: Liquibase: SQL changelogs. The quartz-tables-mysql-innodb.sql file next to it is the jar script without its DROP TABLE lines, the same file as the Flyway migration. Point spring.liquibase.change-log at the changelog if it is not at Boot’s default classpath:/db/changelog/db.changelog-master.yaml.

Table 2. The three Liquibase variants and the schema each produced
Variant Secondary indexes Name columns Notes

sqlFile on a copy of tables_mysql_innodb.sql (above)

21

VARCHAR(190)

identical schema to the Flyway route

sqlFile with path: org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql, pointing at the file inside the jar

21

VARCHAR(190)

works, but the script’s DROP TABLE lines run with the changeset; acceptable only on an empty database

include of the bundled org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml

20

VARCHAR(200)

a different schema: changeset quartz-init, author quartz, widest index key 2144 bytes instead of 2064

Source: liquibase.quartz.init.xml (v2.5.2). The columns are from information_schema after each run.

The bundled changelog is database-neutral (a blob_type property is BYTEA on PostgreSQL and BLOB elsewhere) and is included with <include file="org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml"/>, or in YAML as include: {file: …​}. It works on MySQL and MariaDB, but it is not the same schema as the InnoDB script: it names no engine (so the server default applies), keeps the 200-character names, and has 20 indexes. Pick one source of truth per environment and do not mix them. For the general workflow see Liquibase: Getting Started.

Transactions and locking

This is the section that decides whether the store is correct. It was measured by switching on the server’s general log and reading every statement that one scheduler sent.

What one firing sends to the database

The log of a clustered node (default settings, acquireTriggersWithinLock=false, batch size 1) showed this sequence on one connection for the first firing of the overdue-loan trigger on MySQL. The statements are shortened; the order is as logged. On MariaDB the acquisition transaction was identical (with SET STATEMENT SQL_SELECT_LIMIT=1 instead of sql_select_limit); the firing transaction was not captured in that trace, but the log of the isolation runs showed the same SELECT …​ FROM QRTZ_LOCKS …​ FOR UPDATE statements, 23 to 28 per run.

sequenceDiagram participant A as Node A (scheduler thread) participant DB as InnoDB (QRTZ_TRIGGERS, QRTZ_LOCKS) participant B as Node B A->>DB: SET autocommit=0 A->>DB: SELECT TRIGGER_NAME ... FROM QRTZ_TRIGGERS WHERE TRIGGER_STATE = WAITING AND NEXT_FIRE_TIME <= now ORDER BY NEXT_FIRE_TIME, PRIORITY DESC (limit 1) A->>DB: UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = ACQUIRED WHERE ... AND TRIGGER_STATE = WAITING Note over A,B: no lock statement yet. If B tries the same trigger, its UPDATE waits for A's row and then matches 0 rows A->>DB: INSERT INTO QRTZ_FIRED_TRIGGERS ... A->>DB: COMMIT A->>DB: SET autocommit=0 A->>DB: SELECT * FROM QRTZ_LOCKS WHERE LOCK_NAME = TRIGGER_ACCESS FOR UPDATE DB-->>A: row, exclusive lock held until COMMIT Note over B,DB: any other node asking for TRIGGER_ACCESS now waits (innodb_lock_wait_timeout, 50 s by default) A->>DB: UPDATE QRTZ_FIRED_TRIGGERS ... STATE = EXECUTING, UPDATE QRTZ_TRIGGERS ... next fire time A->>DB: COMMIT

Source: JobStoreSupport.java (v2.5.2), methods acquireNextTriggers and triggersFired. The log was read from mysql.general_log.

Two facts in the diagram differ from what many articles say. First, trigger acquisition does not take the TRIGGER_ACCESS lock by default: in acquireNextTriggers the lock name is TRIGGER_ACCESS only when acquireTriggersWithinLock is true or the batch size is greater than one, and otherwise it is null. Acquisition is optimistic: the UPDATE …​ WHERE TRIGGER_STATE = 'WAITING' either changes the row or matches nothing, and Quartz then tries the next trigger. Second, the lock that serialises the nodes is taken when the trigger fires (triggersFired), and by every store that uses the default locking, not only on MySQL. With InnoDB the optimistic update is safe because an UPDATE waits for the row lock of a concurrent update and then re-evaluates the condition on the latest committed row. In the measurement every scheduled time was executed exactly once.

Row locks and the default delegate

Quartz’s default lock statement is SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = ? AND LOCK_NAME = ? FOR UPDATE, from StdRowLockSemaphore. selectWithLockSQL is not set for MySQL or MariaDB (Quartz overrides it only for MSSQLDelegate), so the default is what runs, and no property is needed. The two lock rows, TRIGGER_ACCESS and STATE_ACCESS, are inserted by Quartz itself the first time they are requested, not by the DDL script: the integration test below asserts that two rows exist for the scheduler after startup.

Whether that statement locks anything depends on the engine, which is the whole reason the legacy script matters. The measurement used two connections and innodb_lock_wait_timeout=2 on the second one:

Table 3. A second connection asking for the lock row held by the first
Engine and server Outcome

InnoDB, MySQL 9.7.2

second connection waited 2091 ms, then failed with SQLState 40001, error 1205, Lock wait timeout exceeded; try restarting transaction

InnoDB, MariaDB 11.8.9

waited 2018 ms, then failed with SQLState HY000, error 1205, the same message

MyISAM, both servers

the row came back in 5 ms: the statement did not wait, because FOR UPDATE has no effect on a table engine without row locks

The server’s default innodb_lock_wait_timeout is 50 seconds on both. Quartz holds the lock row for the duration of one short transaction, so a wait close to that value means something else is wrong, typically a long transaction elsewhere that touches the Quartz tables, or a QuartzJobBean that started a transaction and holds it while it works. Error 1205 in the logs is the symptom to search for.

The isolation level

Quartz runs its statements at whatever isolation level the connection has. The Quartz source sets one only when asked, through two properties: org.quartz.jobStore.txIsolationLevelSerializable (in JobStoreSupport, applied to the connections it opens for itself) and org.quartz.jobStore.txIsolationLevelReadCommitted (in JobStoreCMT, which Spring’s LocalDataSourceJobStore extends, applied to the non-managed connections used by the scheduler thread). Both default to false. The general log shows what each setting does on the wire:

Table 4. Isolation statements seen by the server in an 8-second run, per setting
Setting What the server received

defaults

no SET …​ TRANSACTION ISOLATION statement at all; a connection taken from the pool reported @@transaction_isolation = REPEATABLE-READ on both servers, which is the default of both

txIsolationLevelReadCommitted=true

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED followed by SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ around each non-managed transaction (33 to 40 pairs in the runs); connections used inside Spring-managed transactions are not touched, and a pool connection still reported REPEATABLE-READ

txIsolationLevelSerializable=true

the same pairs with SERIALIZABLE (16 pairs in the run)

spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED

every pooled connection reported READ-COMMITTED, no per-transaction statements

What does Quartz need? Its own protocol takes the isolation it needs from SELECT …​ FOR UPDATE on QRTZ_LOCKS, a locking read that sees the latest committed data and blocks on the lock regardless of the level, so the default REPEATABLE READ works, and so did READ COMMITTED (the Hikari variant). In a 20-second stress run with three clustered nodes, ten worker threads each and 150 triggers repeating every second (3000 possible firings), the default and the Hikari READ COMMITTED variants both produced no InnoDB deadlock: the counter stayed at zero and the server never recorded a "latest detected deadlock". There were 24 successful runs (12 on MySQL with the defaults and with READ COMMITTED, 4 on MySQL with the binary log disabled, 8 on MariaDB), and in 23 of them every scheduled time was executed exactly once. In one MySQL run with READ COMMITTED one scheduled time was recorded twice; it did not reproduce in the five later runs of that variant, the details were not captured, and nothing else in the log explains it, so it is reported rather than explained. Treat it as one more reason to write idempotent jobs, which is advice that holds regardless of the database. Throughput varied from a few hundred to about 3000 firings per run on MySQL and from about 2000 to 3000 on MariaDB, but the machine was shared with other test runs, so no speed comparison between the two servers is claimed.

The practical rule that follows: leave the isolation level alone. If a production system reports Deadlock found when trying to get lock; try restarting transaction (error 1213) in Quartz’s logs, the first thing to try is spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED for the Quartz data source, because READ COMMITTED takes fewer gap locks in InnoDB. That recommendation comes from InnoDB’s documented behaviour and from the measured fact that Quartz works at that level; it is not a fix for a deadlock that was reproduced here. The general discussion of isolation and locking is on Transaction Isolation and Locking.

BLOB handling

JOB_DATA in QRTZ_JOB_DETAILS and QRTZ_TRIGGERS, CALENDAR in QRTZ_CALENDARS and BLOB_DATA in QRTZ_BLOB_TRIGGERS are BLOB columns in the script, and a MySQL or MariaDB BLOB holds at most 65,535 bytes (information_schema reported 65535 for all four). The default JobDataMap is a Java-serialised object, so a job that carries a large payload, or a calendar that lists thousands of dates, can exceed it. The outcome depends on the server’s sql_mode:

Table 5. A 70,000-byte payload in a JobDataMap, on both servers
Mode Outcome

strict (the default of both servers: STRICT_TRANS_TABLES)

addJob failed at once with Data truncation: Data too long for column 'JOB_DATA' at row 1 (MysqlDataTruncation and MariaDbDataTruncation)

non-strict session

addJob returned without error, the row held exactly 65,535 bytes, and getJobDetail then failed with a bare EOFException: the job was stored corrupt and could not be read back

The non-strict case needed effort to produce: both drivers re-enable strict mode on their own sessions. With MariaDB Connector/J sessionVariables=sql_mode=NO_ENGINE_SUBSTITUTION was enough. With MySQL Connector/J that setting alone left the session at STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION and the error stayed, and jdbcCompliantTruncation=false had to be added. So an application that runs on defaults gets the loud failure; only a server or URL deliberately configured for old MySQL behaviour gets the silent corruption. The remedy is to widen the columns, which the script does not do:

A migration that widens the BLOB columns to 4 GB (run as plain SQL on both servers)
ALTER TABLE QRTZ_JOB_DETAILS MODIFY JOB_DATA LONGBLOB NULL;
ALTER TABLE QRTZ_TRIGGERS    MODIFY JOB_DATA LONGBLOB NULL;
ALTER TABLE QRTZ_CALENDARS   MODIFY CALENDAR LONGBLOB NOT NULL;

Source: MySQL: The BLOB and TEXT Types. After these statements a 70,000-byte payload was stored and read back intact on both servers. Ship them as a V2__ Flyway migration or a second Liquibase changeset; they were run here as plain SQL, not through a migration tool. A better design is a small JobDataMap that carries an identifier (memberId, loanId) and loads the large data from the business tables.

org.quartz.jobStore.useProperties=true is the other documented answer to serialisation: the JobDataMap is stored as Java properties text. It was run on both servers, with two results. The Bookshelf schedule failed at startup because its overdue job holds an int counter: JobDataMap values must be Strings when the 'useProperties' property is set. Key of offending value: runs. Plain Quartz with string values stored #|#Mon Oct 05 12:32:03 CEST 2026|memberId=42| (the | marks line ends) and read it back, while a Long value failed with the same message. So useProperties removes Java serialisation (and its class-version fragility) at the cost of strings only, and it does not lift the 64 KB limit.

Names, utf8mb4 and key lengths

The name columns are 190 characters wide, not the 200 of the other databases' scripts, and SCHED_NAME is 120. Two measurements show what that means:

Table 6. Where the widths bite
Test Result on both servers

a job name of 190 characters

stored

a job name of 191 characters

Data too long for column 'JOB_NAME' at row 1

a scheduler name of 121 characters

Data too long for column 'SCHED_NAME' at row 1, at startup

primary key of one VARCHAR(120) and two VARCHAR(n) columns in utf8mb4, row format DYNAMIC, n = 190, 191, 192, 200

all created (the widest key in the real schema is 2064 bytes, below the 3072-byte limit of DYNAMIC)

the same key in row format COMPACT, n = 190 and 191

created

the same key in row format COMPACT, n = 192 and 200

MySQL: Specified key was too long; max key length is 767 bytes; MariaDB: Index column size too large. The maximum column size is 767 bytes

Source: MySQL: InnoDB Limits and MySQL: InnoDB Row Formats. The tests created the tables in a utf8mb4 database on both servers.

The 190 is explained by the last two rows: in COMPACT and REDUNDANT row formats, the default in MySQL 5.6, an index column may use at most 767 bytes, and 191 characters of utf8mb4 (4 bytes each) is 764. The 190-character columns are therefore a safe choice for old servers, and today’s servers use DYNAMIC by default, where the 200-character columns of the legacy script would also have fitted. The consequence for the application is the opposite of a problem on modern servers and a trap on old ones: a Quartz job or trigger name may be at most 190 characters, and a name generated from user input (a loan id, a book title) should be limited or hashed before it becomes a JobKey. A scheduler name that long is not realistic.

Which character set is used does not matter to Quartz as long as it is a multi-byte-safe one: names are plain identifiers, and the collations of the two servers differ only in their defaults (see the MariaDB section).

Case sensitivity of table names

Quartz builds its SQL as the table prefix QRTZ_ plus the upper-case table name, for example QRTZ_JOB_DETAILS. Whether qrtz_job_details is the same table depends on lower_case_table_names, which is fixed when the server’s data directory is initialised. On both Linux images it was 0, case-sensitive. The test created tables in four ways:

Table 7. Table-name case on a case-sensitive server (lower_case_table_names=0) and on one started with 1
Setup Outcome

stock script, upper-case names, server 0

works (what every other run on this page used)

every identifier lower-cased (qrtz_job_details), server 0, default prefix

Quartz failed at startup: Table 'qlower.QRTZ_JOB_DETAILS' doesn’t exist

the same tables with org.quartz.jobStore.tablePrefix=qrtz_

failed: Table 'qlower3.qrtz_JOB_DETAILS' doesn’t exist, because only the prefix is configurable and the rest of the name stays upper-case

only the prefix lower-cased (qrtz_JOB_DETAILS), server 0, tablePrefix=qrtz_

works

server started with --lower-case-table-names=1, lower-case script, default prefix

works, on MySQL and on MariaDB

the same server, stock upper-case script

works, and the tables are stored as qrtz_job_details and so on

The pitfall is a database dump taken on Windows or macOS (where the common default is 1 or 2, so the tables are stored lower-case) and restored on a Linux server with 0: the application finds no tables. Keep the stock upper-case names and the default prefix, or make the whole fleet use the same lower_case_table_names from the start. On MySQL 8.0 and later the setting cannot be changed after initialisation, which is why the test passed it as a container start argument (a server configured differently was not available to test, so what happens when you change it on an existing server is taken from the documentation).

MariaDB differences

The same page applies to MariaDB; only these differences were observed.

Table 8. MySQL 9.7.2 and MariaDB 11.8.9 side by side, as measured
Aspect MySQL (mysql:9) MariaDB (mariadb:11)

DDL script

tables_mysql_innodb.sql

the same file; there is no MariaDB script and Boot maps the platform to mysql_innodb

Delegate

StdJDBCDelegate

StdJDBCDelegate

Default driver and URL

MySQL Connector/J 9.7.0, jdbc:mysql://

MariaDB Connector/J 3.5.10, jdbc:mariadb://

Cross-driver use

jdbc:mariadb:// against mysql:9 passed the Bookshelf run and a restart

jdbc:mysql:// against mariadb:11 passed the same run

Authentication plugin of the test user

caching_sha2_password

mysql_native_password

Server default collation

utf8mb4_0900_ai_ci

utf8mb4_uca1400_ai_ci

max_allowed_packet

64 MiB

16 MiB

Default sql_mode

ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

IGNORE_SPACE,STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

Isolation variable

@@transaction_isolation (tx_isolation does not exist)

both @@transaction_isolation and @@tx_isolation exist

Row limit on trigger acquisition (log)

SET sql_select_limit=1 around the query

SET STATEMENT SQL_SELECT_LIMIT=1 FOR SELECT …​

Flyway warning

Using MySQL 9.7 which is newer than the version Flyway has been verified with

Using MariaDB 11.8 which is newer than the version Flyway has been verified with

Engines available

InnoDB (default), MyISAM, and others

InnoDB (default), MyISAM, Aria, and others

Source: MariaDB: MariaDB versus MySQL, Compatibility and MariaDB: About MariaDB Connector/J. Every value is from the running containers.

None of these changes Quartz’s behaviour. The two that deserve a decision are the driver and the authentication.

The driver follows the server, not the other way round. All four pairs (MySQL Connector/J or MariaDB Connector/J, against mysql:9 or mariadb:11) ran the Bookshelf jobs, stored the serialised JobDataMap and survived a restart, and Boot picked tables_mysql_innodb.sql in each case. Use the driver your organisation supports for the server, and let the URL scheme be consistent with it, because Boot derives the platform from the scheme. MariaDB Connector/J does not accept jdbc:mysql: URLs unless told to (its permitMysqlScheme option); this page did not test that option.

Authentication with MySQL 9 depends on TLS. The test user on mysql:9 uses caching_sha2_password, MySQL’s default. A fresh server has no cached password hash for a user, and a driver that connects without TLS then cannot complete the first login unless it may retrieve the server’s RSA public key. Measured after FLUSH PRIVILEGES (which empties that cache): Connector/J with sslMode=DISABLED failed with Public Key Retrieval is not allowed; adding allowPublicKeyRetrieval=true connected; sslMode=REQUIRED connected over TLS_AES_128_GCM_SHA256; MariaDB Connector/J with sslMode=disable connected, and sslMode=trust connected over TLS. Before the FLUSH PRIVILEGES the DISABLED connection also worked, because the earlier TLS login had cached the hash, which is exactly why the failure appears on a new deployment or after a restart of the server rather than on a developer machine. sslMode=VERIFY_CA failed against the container with CertPathValidatorException: Path does not chain with any of the trust anchors, as expected for a self-signed certificate: production URLs should use VERIFY_CA or VERIFY_IDENTITY with the server’s real certificate. On MariaDB, whose test user uses mysql_native_password, every variant connected.

Clustering

Clustering works on both servers with the same configuration as on PostgreSQL: isClustered=true, instanceId=AUTO, identical properties on every node, and InnoDB tables. It was measured with clusterCheckinInterval=2000.

Table 9. Clustering measured on MySQL 9.7.2 and MariaDB 11.8.9
Test MySQL MariaDB

three schedulers in one JVM, 12 seconds, cacheRefresh every second and the overdue trigger every two seconds

cacheRefresh: 15 distinct scheduled times, 15 executions, 0 duplicated, all on one node. Overdue: 7 and 7, 0 duplicated, split 1 and 6 between two nodes.

cacheRefresh: 14 and 14, 0 duplicated, all on one node. Overdue: 7 and 7, 0 duplicated, split 1 and 6.

two nodes, 10 seconds (an earlier run of the same scenario)

no duplicate executions, work shared

no duplicate executions, work shared

kill -9 of a node running a requestsRecovery=true job of 25 seconds, the other node in standby and then resumed

QRTZ_FIRED_TRIGGERS showed the job EXECUTING with requests_recovery=1 on the dead node; the survivor re-ran it with isRecovering() true 9527 ms after the kill; the dead node’s row left QRTZ_SCHEDULER_STATE

same, recovered after 9330 ms

Source: Quartz: Configure Clustering with JDBC-JobStore. The runs are the Bookshelf cluster scenarios of the verification project: three in-JVM schedulers and a separate JVM killed with kill -9.

Three observations carry over from the other databases. Quartz does not balance load: the node that asks first wins, so one node ran every cacheRefresh firing. Failover takes about the check-in interval plus Quartz’s fixed 7.5 s margin plus one check-in cycle: with a 2-second interval the recovery took 9.3 to 9.5 seconds, so with the default 7.5-second interval expect roughly twice that or more, and size the job’s idempotency accordingly. And the in-flight job is re-run only if requestsRecovery is true. The full explanation is on Clustering. As the Quartz documentation requires, the clocks of the nodes (and of the database host) must be synchronised, which these single-machine tests could not exercise.

Testcontainers tests for the Bookshelf jobs

The tests start the database, let Flyway create the tables from V1__quartz_tables.sql, start the Bookshelf service with the JDBC job store and a cluster configuration, and check that the jobs were stored in InnoDB tables, that one of them fires and that the node checked in. @ServiceConnection supplies the container’s connection details to the Boot data source. The Bookshelf schedule is the one from Getting Started with Spring Boot, with a property that makes the cache refresh fire every second.

BookshelfMySqlIT.java: the Bookshelf jobs on a real MySQL
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.springframework.test.annotation.DirtiesContext;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mysql.MySQLContainer;

@SpringBootTest(properties = {
        "bookshelf.fast=true",
        "spring.flyway.enabled=true",
        "spring.flyway.locations=classpath:db/migration/mysql",
        "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.StdJDBCDelegate"
})
@Testcontainers
@DirtiesContext
class BookshelfMySqlIT {

    @Container
    @ServiceConnection
    static final MySQLContainer MYSQL = new MySQLContainer("mysql:9");

    @Autowired
    Scheduler scheduler;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void bookshelfJobsAreStoredInInnoDbAndFire() throws Exception {
        assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
        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);

        // every table is InnoDB, so SELECT ... FOR UPDATE on QRTZ_LOCKS really locks a row
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'QRTZ\\_%' AND engine <> 'InnoDB'",
                Integer.class)).isZero();

        // 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, and the two cluster lock rows exist
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_SCHEDULER_STATE WHERE SCHED_NAME = 'BookshelfScheduler'",
                Integer.class)).isEqualTo(1);
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_LOCKS WHERE SCHED_NAME = 'BookshelfScheduler'",
                Integer.class)).isEqualTo(2);

        // the isolation level Quartz runs under is the server default
        assertThat(jdbc.queryForObject("SELECT @@transaction_isolation", String.class)).isEqualTo("REPEATABLE-READ");
    }
}

Source: Testcontainers: MySQL Module and Spring Boot: Testcontainers. The test was run on 5 October 2026 and passed.

The MariaDB test is the same file with three lines changed: the import org.testcontainers.mariadb.MariaDBContainer, the field static final MariaDBContainer MARIADB = new MariaDBContainer("mariadb:11"); and the class name BookshelfMariaDbIT. It was run and passed too (one test each, 8.7 s for MySQL and 10.8 s for MariaDB including the container start, in one Maven run).

What the runs established:

  • @ServiceConnection works with both 2.x container classes, so no @DynamicPropertySource is needed. The Boot data source got jdbc:mysql:// or jdbc:mariadb:// URLs from the containers' getJdbcUrl(), and the platform detection chose the InnoDB script (the migration made that moot).

  • Every QRTZ_ table was InnoDB, the two cluster lock rows existed, and @@transaction_isolation was REPEATABLE-READ on both servers.

  • @DirtiesContext matters when two such classes run in one JVM. Without it Spring caches the first test’s application context, whose scheduler stays alive while its container is stopped: in the run without the annotation the second test’s log filled with Failed to validate connection and marked as broken warnings from the first context’s scheduler thread talking to a dead MariaDB. With the annotation the warnings were gone and both tests still passed.

  • The Spring Boot 4.1 test project in this verification also had Liquibase on its classpath; if both Flyway and Liquibase are present, disable the one you do not use (spring.liquibase.enabled=false) or Boot will look for a Liquibase changelog.

The wider setup (reuse, CI runners, @ServiceConnection) is on Spring Boot Integration Tests with Testcontainers, and how to test Quartz jobs in general is on Testing.

Business data and Quartz data on MySQL

If the business data is also on MySQL or MariaDB, the simplest layout is one Hikari data source shared with Quartz: a single DataSource bean is picked up for the job store automatically, and the jobs can then take part in Spring transactions through LocalDataSourceJobStore. With a second data source you need @QuartzDataSource (and @QuartzTransactionManager for its own transaction manager), exactly as on the Job Stores & Persistence page; 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). A dedicated database for the QRTZ_ tables keeps the cluster’s lock rows out of the way of long business transactions and lets you give the Quartz user SELECT, INSERT, UPDATE and DELETE only on that database; the grants used in the tests were GRANT ALL ON <database>.* for convenience, and a narrower grant was not measured. Entities and repositories are in Spring Data JPA, and the engine-level background is in the SQL databases section.

Pitfalls

Table 10. MySQL and MariaDB pitfalls, each from a run or from the sources
Symptom Cause Fix

Jobs run twice, or clustering seems to work but is unsafe

tables are MyISAM, so SELECT …​ FOR UPDATE on QRTZ_LOCKS holds nothing (second connection got the row in 5 ms)

use tables_mysql_innodb.sql; check engine <> 'InnoDB' in information_schema.tables

Slow trigger acquisition, scans in EXPLAIN

the legacy tables_mysql.sql creates no secondary indexes

run the InnoDB script (21 secondary indexes)

Specified key was too long; max key length is 1000 bytes

MyISAM forced onto utf8mb4 columns

do not use MyISAM for Quartz; it also has no row locks

Specified key was too long; max key length is 767 bytes or Index column size too large

old COMPACT or REDUNDANT row format with columns wider than 191 utf8mb4 characters

use DYNAMIC (the default on current servers) or the InnoDB script’s 190-character columns

Data too long for column 'JOB_DATA' at row 1

serialised JobDataMap over 65,535 bytes in a BLOB

ALTER …​ MODIFY JOB_DATA LONGBLOB, or keep the map small

EOFException from getJobDetail, a job that cannot be loaded

non-strict sql_mode silently truncated the BLOB at 65,535 bytes

keep the default strict mode; widen the column; delete and re-create the damaged job

Table 'x.QRTZ_JOB_DETAILS' doesn’t exist although the tables are there

lower-case table names restored onto a server with lower_case_table_names=0

keep the stock upper-case names; align lower_case_table_names across environments

Public Key Retrieval is not allowed

MySQL 9 caching_sha2_password over a connection without TLS, on a fresh server or after FLUSH PRIVILEGES

use TLS (sslMode=REQUIRED or stricter); allowPublicKeyRetrieval=true only on a trusted network

Stored jobs and triggers vanish after every restart

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

use never with Flyway or Liquibase

Table 'QRTZ_JOB_DETAILS' doesn’t exist at startup on a real server

Boot’s default embedded creates the schema only on embedded databases

run the migration, or initialize-schema=always in development only

Lock wait timeout exceeded (error 1205) in the Quartz logs

a transaction elsewhere holds QRTZ_LOCKS or the QRTZ_TRIGGERS rows, for example a long job transaction

find and shorten the transaction; do not raise innodb_lock_wait_timeout first

Deadlock found when trying to get lock (error 1213)

InnoDB’s lock ordering under load (not reproduced in 24 stress runs here)

retry is built in; if frequent, try hikari.transaction-isolation=TRANSACTION_READ_COMMITTED

Job name over 190 characters fails

VARCHAR(190) columns

limit or hash generated keys

Flyway logs which is newer than the version Flyway has been verified with

a server newer than the Flyway release

a warning only; it ran without a problem here

Warnings Failed to validate connection from a scheduler in a test log

a cached Spring context whose container was stopped

@DirtiesContext on the test class

quartz.properties seems ignored

Boot does not load it; the properties come from spring.quartz.properties.*

put the keys in application.yml

What changed

Table 11. Facts about Quartz on MySQL and MariaDB that changed or that older articles get wrong
Topic Before Now (Quartz 2.5.2, Boot 4.1.1)

Which MySQL script

articles say tables_mysql.sql is the MyISAM script and tables_mysql_innodb.sql the InnoDB one

the legacy script has no ENGINE clause and creates InnoDB tables on current servers, but without indexes and with 200-character names; tables_mysql_innodb.sql is the one Boot selects and the one to use

MariaDB script

forum answers suggest a MariaDB-specific script

none exists; Boot maps MariaDB to mysql_innodb

Name length

200 characters in the legacy script

190 in the InnoDB script (the key-prefix limit of old row formats)

Trigger acquisition locking

articles describe TRIGGER_ACCESS being locked to acquire triggers

with the defaults it is not; acquisition is an optimistic UPDATE …​ WHERE TRIGGER_STATE = 'WAITING' and the lock is taken in triggersFired

Isolation

older guides tell you to set txIsolationLevelSerializable or READ COMMITTED for MySQL

Quartz sets nothing by default and ran correctly at REPEATABLE READ and READ COMMITTED; no deadlock was reproduced

Connector/J artifact and driver class

artifact mysql:mysql-connector-java, driver class com.mysql.jdbc.Driver

artifact com.mysql:mysql-connector-j and driver class com.mysql.cj.jdbc.Driver (the run used the Boot-managed 9.7.0)

MySQL default authentication

mysql_native_password

caching_sha2_password on mysql:9 (measured); the first login without TLS needs public-key retrieval

Flyway

one artifact for every database

database modules since Flyway 10: flyway-mysql covers MySQL and MariaDB

Testcontainers

org.testcontainers:mysql and org.testcontainers.containers.MySQLContainer

org.testcontainers:testcontainers-mysql 2.0.5 and org.testcontainers.mysql.MySQLContainer; likewise testcontainers-mariadb and org.testcontainers.mariadb.MariaDBContainer

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.

Source: Quartz releases and the measurements on this page.

Where to go next