HomeSpring BootSpring Boot MySQL Integration: Config, HikariCP, Flyway and Testcontainers
Spring Boot

Spring Boot MySQL Integration: Config, HikariCP, Flyway and Testcontainers

Connect Spring Boot 3 to MySQL the production-ready way: Connector/J, SSL mode, UTC time zones, HikariCP pool sizing, Flyway migrations (with MySQL's non-transactional DDL), JSON and IDENTITY mappings, MySQL-only queries, Docker Compose and Testcontainers.

Quick Start: Minimum Setup

The smallest working setup is two dependencies and three properties: - `spring-boot-starter-data-jpa` - `com.mysql:mysql-connector-j` - `spring.datasource.url`, `username` and `password` Boot then configures HikariCP, Hibernate and Spring Data for you. The rest of this page covers what those defaults leave open: SSL, time zones, pool sizing, schema migrations, MySQL-friendly mappings, MySQL-only queries, and tests against a real database. Examples target Spring Boot 3.x (Hibernate 6) and MySQL 8.x.

Defaults You Should Not Leave Alone

| Area | Risk with defaults | What this page does | |---|---|---| | TLS | Unencrypted or unverified connections | `sslMode=VERIFY_IDENTITY` in production | | Time zones | Values shift between environments | Store UTC and set `hibernate.jdbc.time_zone` | | Pool size | Total connections exceed `max_connections` | Size pools from the server limit | | Idle connections | "Communications link failure" after a proxy drops them | `max-lifetime` and `keepalive-time` | | Schema | Hibernate changes tables silently | Flyway plus `ddl-auto: validate` | | Migrations | MySQL DDL cannot be rolled back | One DDL statement per migration | | Character set | `utf8` can't store emoji | Use `utf8mb4` | | Tests | H2 hides collation and JSON behaviour | Testcontainers with real MySQL |

Dependencies

Three artifacts cover it. - The **JPA starter** brings Hibernate, Spring Data and HikariCP. - **`mysql-connector-j`** is the current name of the JDBC driver (the old `mysql-connector-java` coordinates were relocated). Boot's BOM manages its version, so don't pin one. - **Flyway** should go in from the first table so the schema is versioned from the start. From Flyway 10, database support is split into modules, so you need both `flyway-core` and `flyway-mysql`.
XML
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-mysql</artifactId>
    </dependency>
</dependencies>

Datasource and JPA Configuration

The minimum is a URL, a username and a password. Boot infers the driver class from the URL, and Hibernate detects the MySQL dialect by asking the database, so `driver-class-name` and `hibernate.dialect` are not needed. `MySQL8Dialect` is deprecated in Hibernate 6 (Boot 3), so leave the dialect unset. **Time zones:** store everything in UTC and set `hibernate.jdbc.time_zone`. That is more reliable than letting the driver guess the server's zone, and it stops values shifting between environments. **Multi-line URLs are a trap.** A YAML folded scalar (`>`) joins lines with spaces, which silently breaks the URL. Keep it on one line.
yaml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb
    username: ${DB_USER:appuser}
    password: ${DB_PASSWORD}
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate          # Flyway owns the schema; Hibernate only checks it matches
    properties:
      hibernate:
        format_sql: true
        jdbc:
          time_zone: UTC          # read and write timestamps as UTC
          batch_size: 50

---
spring:
  config:
    activate:
      on-profile: dev
  datasource:
    # No TLS to a local container; the public key option is a dev-only convenience.
    url: jdbc:mysql://localhost:3306/mydb?sslMode=DISABLED&allowPublicKeyRetrieval=true

---
spring:
  config:
    activate:
      on-profile: prod
  datasource:
    # One line. Verify the server certificate and hostname.
    url: jdbc:mysql://prod-db.internal:3306/mydb?sslMode=VERIFY_IDENTITY&rewriteBatchedStatements=true

JDBC URL Options: What to Use and What to Skip

| Option | Use it? | Notes | |---|---|---| | `sslMode` | Yes | `DISABLED`, `PREFERRED`, `REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY`. Use `VERIFY_IDENTITY` in production. | | `useSSL`, `requireSSL` | No | Deprecated in favour of `sslMode` | | `allowPublicKeyRetrieval=true` | Dev only | Needed when connecting without TLS to a `caching_sha2_password` server, typically a local Docker container. It weakens security. | | `rewriteBatchedStatements=true` | Yes, for batch writes | Combines batched statements into multi-row ones. Does **not** speed up `IDENTITY` inserts (see the mappings section). | | `useUnicode`, `characterEncoding` | No | Unnecessary with current drivers, which follow the server's `utf8mb4` | | `serverTimezone` | Avoid | Prefer UTC everywhere plus `hibernate.jdbc.time_zone` |

Connection Pool Sizing with HikariCP

Hikari works well with its defaults, and most tuning advice makes things worse. The setting that matters is `maximum-pool-size`, and bigger is not better. A pool of 10 serves a surprising amount of traffic, because a connection is busy only while a query runs. A common starting point is about twice the database server's CPU cores, then measure. - **Leave `minimum-idle` unset** so the pool stays at a fixed size. Hikari's guidance is that a fixed pool performs best. - **Do the sum.** MySQL allows 151 connections by default, and every application instance has its own pool. Ten replicas with a pool of 20 want 200 connections, and MySQL starts refusing them. Divide the server limit by the instance count, and leave room for admin and migration connections. - **Connection lifetime causes the mysterious errors.** If something between the app and MySQL closes idle connections (a lowered `wait_timeout`, a firewall, a cloud load balancer or NAT gateway), the next use of a stale pooled connection fails with "Communications link failure". Set `max-lifetime` below the shortest such timeout and use `keepalive-time` to ping idle connections. - **Don't set `connection-test-query`.** With a JDBC 4 driver, Hikari validates with the driver's `isValid` call, and the Hikari docs recommend against a custom query.
yaml
spring:
  datasource:
    hikari:
      pool-name: app-pool
      maximum-pool-size: 10          # minimum-idle unset: fixed-size pool
      connection-timeout: 10000      # fail fast if the pool is exhausted (default 30s)
      max-lifetime: 1500000          # 25 min: below any wait_timeout / proxy idle timeout (default 30 min)
      keepalive-time: 300000         # ping idle connections every 5 min
      leak-detection-threshold: 20000   # logs a stack trace if a connection is held over 20s

management:
  endpoints:
    web:
      exposure:
        include: health,metrics

# Watch these in Actuator or your metrics system:
#   hikaricp.connections.active    connections in use right now
#   hikaricp.connections.pending   threads WAITING for a connection. Sustained values above 0
#                                  mean the pool is too small or queries are too slow.
#   hikaricp.connections.timeout   requests that gave up waiting

Schema Migrations with Flyway

With `ddl-auto: validate`, Hibernate never changes the schema. It only fails at startup if entities and database disagree, which is the behaviour you want outside a prototype. Flyway applies versioned scripts in order and records them in a history table, so every environment, including a fresh test database, ends up with the same schema. **Never edit a migration that has been applied elsewhere.** Flyway checksums them and refuses to continue, so write a new one. **MySQL's DDL is not transactional.** `CREATE TABLE` and `ALTER TABLE` cause an implicit commit and cannot be rolled back. If a migration with five statements fails on the third, the first two stay applied and Flyway marks the migration as failed with a half-changed schema that you must repair by hand. So: - Keep migrations small, ideally one DDL statement each. - On large tables, state the online DDL algorithm (`ALGORITHM=INPLACE, LOCK=NONE`) so MySQL refuses an `ALTER` that would lock the table instead of silently doing it. - Be careful with `baseline-on-migrate`. It's meant for adopting Flyway on an existing database once. Left enabled, it can mask a wrong or empty target database by baselining it instead of failing.
sql
-- V1__create_users.sql
CREATE TABLE users (
    id          BIGINT       NOT NULL AUTO_INCREMENT,
    name        VARCHAR(100) NOT NULL,
    email       VARCHAR(255) NOT NULL,
    role        VARCHAR(20)  NOT NULL DEFAULT 'USER',
    active      TINYINT(1)   NOT NULL DEFAULT 1,
    created_at  DATETIME(6)  NOT NULL,
    updated_at  DATETIME(6)  NOT NULL,
    PRIMARY KEY (id),
    -- The _ci collation makes this unique key case-insensitive: alice@x.com and ALICE@x.com collide.
    UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- V2__add_users_bio.sql  (one DDL statement per migration: DDL cannot be rolled back)
ALTER TABLE users ADD COLUMN bio TEXT NULL;

-- V3__index_users_created_at.sql
ALTER TABLE users
    ADD INDEX idx_users_created_at (created_at),
    ALGORITHM=INPLACE, LOCK=NONE;   -- fails loudly instead of locking a big table

-- Only when adopting Flyway on an existing database, once:
--   spring.flyway.baseline-on-migrate=true
--   spring.flyway.baseline-version=1

Schema for the Product Examples

The entity and the queries below use three more tables. Each is its own migration, so a failure leaves at most one half-applied statement. The unique key on `product_views` and the `FULLTEXT` index on `products` are what the upsert and the search query depend on.
sql
-- V4__create_products.sql
CREATE TABLE products (
    id          BIGINT         NOT NULL AUTO_INCREMENT,
    version     BIGINT         NOT NULL DEFAULT 0,
    name        VARCHAR(200)   NOT NULL,
    description TEXT           NULL,
    category    VARCHAR(100)   NULL,
    price       DECIMAL(10,2)  NOT NULL,
    active      TINYINT(1)     NOT NULL DEFAULT 1,
    metadata    JSON           NULL,
    created_at  DATETIME(6)    NOT NULL,
    PRIMARY KEY (id),
    FULLTEXT KEY ft_products (name, description)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- V5__create_product_views.sql
CREATE TABLE product_views (
    product_id BIGINT NOT NULL,
    view_date  DATE   NOT NULL,
    view_count INT    NOT NULL DEFAULT 0,
    PRIMARY KEY (product_id, view_date)      -- the unique key ON DUPLICATE KEY relies on
) ENGINE=InnoDB;

-- V6__create_user_tags.sql
CREATE TABLE user_tags (
    user_id BIGINT NOT NULL,
    tag_id  BIGINT NOT NULL,
    PRIMARY KEY (user_id, tag_id)
) ENGINE=InnoDB;

Entity Mappings That Suit MySQL

**IDs.** MySQL has no sequences, so `GenerationType.IDENTITY` (`AUTO_INCREMENT`) is the right strategy. The catch is that Hibernate must insert each IDENTITY entity immediately to learn its id, so it **cannot batch those inserts**. That is why `rewriteBatchedStatements` does not speed up `saveAll` on IDENTITY entities. For bulk loads, use a JDBC batch insert or a native query. **Everything else:** - Flyway owns the schema, so annotations only need to agree with it, and `ddl-auto: validate` tells you when they don't. Index definitions belong in migrations, not `@Table(indexes)`. - Money is `DECIMAL`, never `float` or `double`. - Store instants as `DATETIME(6)` holding UTC and map them to `Instant`, with the `hibernate.jdbc.time_zone` setting above. - A `@Version` column gives you optimistic locking. - **JSON:** Hibernate 6 maps JSON natively. `@JdbcTypeCode(SqlTypes.JSON)` on a `Map` or a typed class uses Jackson, so a hand-written `AttributeConverter` is no longer needed. The `JSON` type needs MySQL 5.7.8+. If `ddl-auto: validate` complains about the JSON column's type, check your Hibernate version's notes on MySQL JSON validation.
Java
@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)   // AUTO_INCREMENT; disables insert batching
    private Long id;

    @Version
    private long version;                                 // optimistic locking

    @Column(nullable = false, length = 200)
    private String name;

    @Column(columnDefinition = "TEXT")
    private String description;

    private String category;

    @Column(nullable = false, precision = 10, scale = 2)  // DECIMAL(10,2), never double for money
    private BigDecimal price;

    @Column(nullable = false)
    private boolean active = true;

    @JdbcTypeCode(SqlTypes.JSON)                          // native JSON mapping, no converter
    @Column(columnDefinition = "json")
    private Map<String, Object> metadata;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)          // DATETIME(6) holding UTC
    private Instant createdAt;

    protected Product() { }
    // constructor, getters...
}

MySQL-Specific Queries

JPQL covers the portable subset. For MySQL-only features, use native queries and keep them in a repository so the rest of the code stays database-agnostic. Native modifying queries bypass Hibernate's persistence context, so entities already loaded in the same transaction won't reflect them. Put `@Transactional` on the service method, not the repository. Details that are easy to get wrong: - **Upsert:** `ON DUPLICATE KEY UPDATE` only works if a unique key covers the conflict columns. - **Full-text:** it needs a `FULLTEXT` index. InnoDB ignores very short words and stop words by default, so test with realistic data. Natural language mode returns rows by relevance, and paged results need a `countQuery`. - **JSON filtering:** compare with the unquoting operator `->>`, as below, not the raw JSON value. JSON values can't be indexed directly. To filter fast on a JSON field, add a generated column over it and index that. - **`INSERT IGNORE`:** avoid it. It downgrades many errors to warnings, so a bad value can vanish silently. The explicit `ON DUPLICATE KEY` form is safer. - **Window functions** need MySQL 8.0. Map them onto an interface projection by alias instead of unpacking `Object[]` by position.
Java
public interface PriceRankView {
    Long getId();
    String getName();
    String getCategory();
    BigDecimal getPrice();
    Long getPriceRank();
}

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Upsert. Needs the primary/unique key (product_id, view_date) from V5.
    @Modifying
    @Query(value = """
            INSERT INTO product_views (product_id, view_date, view_count)
            VALUES (:productId, CURDATE(), 1)
            ON DUPLICATE KEY UPDATE view_count = view_count + 1
            """, nativeQuery = true)
    void incrementDailyView(@Param("productId") Long productId);

    // Full-text search. Needs the FULLTEXT index ft_products from V4.
    @Query(value = """
            SELECT * FROM products
            WHERE MATCH(name, description) AGAINST (:query IN NATURAL LANGUAGE MODE)
              AND active = true
            """,
           countQuery = """
            SELECT COUNT(*) FROM products
            WHERE MATCH(name, description) AGAINST (:query IN NATURAL LANGUAGE MODE)
              AND active = true
            """,
           nativeQuery = true)
    Page<Product> fullTextSearch(@Param("query") String query, Pageable pageable);

    // JSON field filter. ->> unquotes the value, so compare it with a string.
    @Query(value = "SELECT * FROM products WHERE metadata ->> '$.featured' = 'true'",
           nativeQuery = true)
    List<Product> findFeatured();

    // Window function (MySQL 8.0+), mapped to a projection by column alias.
    @Query(value = """
            SELECT p.id AS id, p.name AS name, p.category AS category, p.price AS price,
                   RANK() OVER (PARTITION BY p.category ORDER BY p.price ASC) AS priceRank
            FROM products p
            WHERE p.active = true
            """, nativeQuery = true)
    List<PriceRankView> findWithPriceRank();
}

// In a repository for user_tags (needs the primary key (user_id, tag_id) from V6):
//
//   @Modifying
//   @Query(value = """
//           INSERT INTO user_tags (user_id, tag_id) VALUES (:userId, :tagId)
//           ON DUPLICATE KEY UPDATE user_id = user_id
//           """, nativeQuery = true)
//   void addTag(@Param("userId") Long userId, @Param("tagId") Long tagId);

Running MySQL Locally with Docker Compose

A compose file gives everyone the same MySQL version and character set. Pin the version to match production, and prefer the 8.4 LTS release over the older 8.0 line. - **Healthcheck.** It makes dependent services wait until the database actually accepts connections, instead of racing it at startup. - **Init scripts.** Files in `docker-entrypoint-initdb.d` run only when the data volume is empty. To reset the database, run `docker compose down -v`. - **Boot 3.1+ shortcut.** With the `spring-boot-docker-compose` dependency on the classpath, Boot starts the compose file with the application and wires the datasource to the container, so you can skip the datasource properties locally. - **Don't use `ddl-auto=create-drop`** with a Flyway-managed schema. Hibernate would fight the migrations. Drop the volume for a clean database. - The passwords here are local-development defaults only.
yaml
services:
  mysql:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}   # local development only
      MYSQL_DATABASE: mydb
      MYSQL_USER: appuser
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-apppassword}
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init:/docker-entrypoint-initdb.d    # .sql files run only on first start of an empty volume
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 10

volumes:
  mysql_data:

Integration Tests with Testcontainers: Dependencies

H2 in MySQL mode is only an approximation. It hides exactly the behaviours that bite in production: collations, strict mode, JSON functions, full-text search and DDL behaviour. Run repository tests against a real MySQL container. You need `spring-boot-testcontainers`, the Testcontainers MySQL module and the `junit-jupiter` module (which provides `@Testcontainers` and `@Container`). Let your Boot version's BOM pick the versions. The MySQL module is called `mysql` in the 1.x line and `testcontainers-mysql` in 2.x, and the container class's package changed in 2.x as well, so check the names for your version.
XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>   <!-- testcontainers-mysql on Testcontainers 2.x -->
    <scope>test</scope>
</dependency>

Integration Tests with Testcontainers: Test Class

On Spring Boot 3.1+, `@ServiceConnection` configures the datasource from the container, which replaces the older `@DynamicPropertySource` boilerplate. Flyway runs against the container too, so the tests also prove your migrations apply cleanly. The second test is the kind only a real MySQL can verify: the unique key on `email` is case-insensitive because of the collation, which H2 would not reproduce. The `User` entity must set `createdAt` and `updatedAt` (both `NOT NULL`), for example with `@CreationTimestamp` and `@UpdateTimestamp`.
Java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)   // keep the real datasource
@Testcontainers
class UserRepositoryMySqlTest {

    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");

    @Autowired
    private UserRepository userRepository;

    @Test
    void savesAndFindsByEmail() {
        userRepository.saveAndFlush(new User("Alice", "alice@example.com"));

        assertThat(userRepository.findByEmail("alice@example.com")).isPresent();
    }

    @Test
    void emailUniquenessIgnoresCase() {
        userRepository.saveAndFlush(new User("Alice", "alice@example.com"));

        assertThatThrownBy(() ->
            userRepository.saveAndFlush(new User("Impostor", "ALICE@example.com")))
            .isInstanceOf(DataIntegrityViolationException.class);
    }
}

Transactions, Isolation and Deadlocks

MySQL's default isolation level is **REPEATABLE READ**, which uses gap locks and is a common source of unexpected lock waits and deadlocks under concurrent writes. Deadlocks (error 1213) are a normal event in a busy MySQL system, not a bug to eliminate entirely. Spring translates them to `DeadlockLoserDataAccessException`, and lock wait timeouts (error 1205) to `CannotAcquireLockException`. Both extend `PessimisticLockingFailureException`, so catch that and retry with a short backoff. - Keep transactions short. - Touch rows in a consistent order across code paths. - Use `@Version` for optimistic locking where conflicts are rare.

Checking the Server's Real Settings

Check the server's actual settings rather than assuming them. These queries show the connection limit your pools must fit under, the idle timeout that decides your `max-lifetime`, the character set, and the isolation level. Other MySQL gotchas: - **Table-name case.** Table names are case-sensitive on Linux and case-insensitive on Windows and macOS by default, so a query that works on a laptop can fail in production. Boot's naming strategy produces lower-case snake_case. Keep to that everywhere, including native queries. - **`utf8mb4`, not `utf8`.** In MySQL, `utf8` is an alias for a three-byte subset that can't store emoji or some CJK characters. - **Strict SQL mode.** Leave it on. It turns data truncation into an error instead of silent corruption.
sql
SHOW VARIABLES LIKE 'max_connections';       -- default 151: ALL application pools together must fit
SHOW VARIABLES LIKE 'wait_timeout';          -- default 28800 (8h); keep Hikari max-lifetime below this
SHOW VARIABLES LIKE 'character_set_server';  -- expect utf8mb4
SELECT @@transaction_isolation;              -- REPEATABLE-READ by default
SELECT @@sql_mode;                           -- should include STRICT_TRANS_TABLES
SHOW ENGINE INNODB STATUS;                   -- the LATEST DETECTED DEADLOCK section shows the last one

Troubleshooting Common MySQL Errors

| Error | Usual cause | Fix | |---|---|---| | `Communications link failure` / `No operations allowed after connection closed` | A proxy, firewall or `wait_timeout` closed an idle pooled connection | Set `max-lifetime` below the shortest timeout and add `keepalive-time` | | `Public Key Retrieval is not allowed` | `caching_sha2_password` over a non-TLS connection | Use TLS, or add `allowPublicKeyRetrieval=true` in **dev only** | | `Too many connections` (1040) | All pools together exceed `max_connections` | Lower `maximum-pool-size` or raise the server limit; size per instance | | `HikariPool-1 - Connection is not available, request timed out` | Pool exhausted by slow queries or leaked connections | Check `hikaricp.connections.pending`; enable `leak-detection-threshold` | | `Access denied for user` | Wrong credentials, or the user isn't allowed from that host | Check the password and the user's host grant | | `Unknown database` | The schema doesn't exist | Create it (Compose `MYSQL_DATABASE`, or `CREATE DATABASE`) | | `Incorrect string value` | Column or connection uses `utf8` (3-byte) | Use `utf8mb4` for the server, database and tables | | `Data too long for column` | Value exceeds the column length under strict mode | Align the entity length with the migration | | `Table 'x' doesn't exist` only on Linux | Table-name case mismatch | Use lower-case snake_case consistently | | `Schema-validation: wrong column type` | Entity type differs from the migration | Align Java type and column type | | Flyway `Migration checksum mismatch` | An applied migration was edited | Never edit applied migrations; add a new one | | Flyway `Detected failed migration` | A multi-statement migration failed midway (DDL isn't rolled back) | Fix the schema by hand, then run Flyway `repair` | | `Deadlock found when trying to get lock` (1213) | Concurrent transactions locking rows in different orders | Retry, keep transactions short, order operations consistently | | Timestamps off by hours | Server, driver and JVM zones differ | Store UTC and set `hibernate.jdbc.time_zone: UTC` |

Common Mistakes

- Setting a large `maximum-pool-size` without checking the server's `max_connections` across all instances. - Leaving `allowPublicKeyRetrieval=true` and `sslMode=DISABLED` in production. - Using several DDL statements in one Flyway migration on MySQL. - Leaving `baseline-on-migrate` enabled permanently. - Using `ddl-auto=update` or `create-drop` alongside Flyway. - Using `float` or `double` for money. - Expecting `saveAll` to batch inserts for `IDENTITY` entities. - Using `INSERT IGNORE`, which hides unrelated errors. - Testing on H2 and trusting the result for MySQL behaviour. - Using `utf8` instead of `utf8mb4`. - Pinning an old `mysql-connector-java` version instead of letting Boot manage `mysql-connector-j`.

Production Checklist

- `sslMode=VERIFY_IDENTITY`, with credentials from environment variables or a secrets manager - `hibernate.jdbc.time_zone: UTC` and `DATETIME(6)` columns holding UTC - `maximum-pool-size` chosen from `max_connections` divided by instance count - `max-lifetime` below every idle timeout between app and database, plus `keepalive-time` - Flyway owns the schema, `ddl-auto: validate`, one DDL statement per migration - `utf8mb4` everywhere, strict SQL mode on - Retry logic for deadlocks (`PessimisticLockingFailureException`) - Hikari metrics (`pending`, `timeout`) monitored - Repository tests run against real MySQL with Testcontainers - Same MySQL major version in development, CI and production

MySQL or PostgreSQL?

Both work well with Spring Boot. The practical differences you will meet in this stack: | | MySQL | PostgreSQL | |---|---|---| | IDs | `IDENTITY` (`AUTO_INCREMENT`), no sequences | Sequences, which allow insert batching | | DDL in migrations | Not transactional | Transactional | | JSON | `JSON` type, index via generated column | `JSONB` with GIN indexes | | Native enums and arrays | Enum type only; no arrays | Native enums and arrays | | Default collation | Case-insensitive | Case-sensitive | If you have no existing constraint, choose the one your team already operates well.