HomeSpring BootSQL Server Integration
Spring Boot

SQL Server Integration

Connecting Spring Boot to SQL Server is mostly painless once you know the handful of places it differs from Postgres or MySQL: the driver encrypts by default, IDENTITY keys quietly disable JDBC batching, Unicode string parameters can wreck index usage, and MERGE is not the safe upsert it looks like. This entry covers setup, entity mappings, native T-SQL, stored procedures, Flyway, and Testcontainers, with the gotchas called out where they bite.

Dependencies and Setup

You need the Microsoft JDBC driver and Spring Data JPA. Spring Boot manages the driver version, so leave it off. Hibernate 6 detects SQL Server from the connection and picks the right dialect on its own, so there is no need to set one (an explicit dialect just triggers a warning on newer versions). Likewise, skip connection-test-query: the driver supports JDBC4 isValid(), which HikariCP uses by default. The one thing that trips up almost everyone on a fresh machine: since mssql-jdbc 10.2 the driver defaults to encrypt=true. Against a local container with a self-signed certificate you will see "PKIX path building failed" unless you set trustServerCertificate=true (dev only) or encrypt=false.
yaml
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <scope>runtime</scope>
</dependency>

# application.yml
spring:
  datasource:
    url: jdbc:sqlserver://localhost:1433;databaseName=mydb;encrypt=true;trustServerCertificate=true
    username: app_user          # not sa; see the note below
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      connection-timeout: 20000
      max-lifetime: 1200000     # keep below any firewall/load balancer idle cut-off
      keepalive-time: 60000
      pool-name: mssql-pool

  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
    properties:
      hibernate:
        jdbc:
          batch_size: 25
        order_inserts: true
        order_updates: true

# Production URLs
# Azure SQL, password auth:
#   jdbc:sqlserver://myserver.database.windows.net:1433;databaseName=mydb;
#   encrypt=true;trustServerCertificate=false;hostNameInCertificate=*.database.windows.net;loginTimeout=30
# Azure SQL, managed identity (no password):
#   jdbc:sqlserver://myserver.database.windows.net:1433;databaseName=mydb;
#   encrypt=true;authentication=ActiveDirectoryManagedIdentity
# Windows integrated auth also needs the mssql-jdbc auth DLL on java.library.path.

Entity Mappings That Actually Matter

Most defaults are fine. A few choices deserve thought. IDENTITY vs SEQUENCE: GenerationType.IDENTITY is the obvious choice for SQL Server, but Hibernate must hit the database after every insert to learn the generated key, which silently turns off JDBC insert batching. If you bulk-load, use a SEQUENCE instead (SQL Server 2012+), with an allocationSize that matches the sequence increment. Strings: Hibernate sends String parameters as NVARCHAR by default. If your column is VARCHAR, SQL Server has to implicitly convert the column to compare, which can turn an index seek into a scan. Either make the column NVARCHAR or add sendStringParametersAsUnicode=false to the JDBC URL (only safe if the whole database is VARCHAR). Concurrency: SQL Server's ROWVERSION type pairs well with optimistic locking, but a plain @Version long works too and is easier to reason about.
Java
@Entity
@Table(name = "products", schema = "dbo",
       indexes = {
           @Index(name = "idx_products_category", columnList = "category"),
           @Index(name = "idx_products_sku", columnList = "sku", unique = true)
       })
public class Product {

    // Batch-friendly alternative to IDENTITY (create the sequence in a migration):
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq")
    @SequenceGenerator(name = "product_seq", sequenceName = "product_seq", allocationSize = 50)
    private Long id;

    // If you do not bulk insert, plain IDENTITY is fine:
    // @Id @GeneratedValue(strategy = GenerationType.IDENTITY)

    @Version
    private long version;

    @Column(name = "product_name", nullable = false, length = 200)   // NVARCHAR(200)
    @Nationalized
    private String name;

    @Column(length = 100)
    @Nationalized
    private String category;

    @Column(length = 50, unique = true)
    private String sku;

    @Column(name = "description", columnDefinition = "NVARCHAR(MAX)")
    private String description;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

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

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;                      // DATETIME2

    @Column(name = "updated_at")
    private Instant updatedAt;

    @Column(name = "thumbnail", columnDefinition = "VARBINARY(MAX)")
    private byte[] thumbnail;

    // UUID primary keys: map to UNIQUEIDENTIFIER. Random UUIDs fragment a clustered
    // index badly; keep the clustered key a BIGINT and use the UUID as a secondary key.
    @Column(name = "public_id", nullable = false, updatable = false)
    private UUID publicId = UUID.randomUUID();

    @PrePersist void onCreate() { createdAt = updatedAt = Instant.now(); }
    @PreUpdate  void onUpdate() { updatedAt = Instant.now(); }
}

Native T-SQL Queries

Reach for native queries when you need something JPQL cannot express. A few notes before the code: Upserts: MERGE is the textbook answer, but without a HOLDLOCK hint it is not safe under concurrency (two sessions can both decide "not matched" and one fails on the unique key), and MERGE has a long history of bugs. The update-then-insert pattern below is simpler and is what most teams end up with. NOLOCK: it does not mean "faster reads", it means dirty reads, including missing or duplicated rows during page splits. If readers blocking writers is the real problem, turn on READ_COMMITTED_SNAPSHOT at the database level instead and drop the hint. Pagination: native queries with Pageable work, but the query needs an ORDER BY, because SQL Server rejects OFFSET/FETCH without one. Parameters: bind variables are never substituted inside string literals. LIKE '%:keyword%' searches for the literal text :keyword. Concatenate the wildcard into the parameter value instead.
Java
public interface ProductRepository extends JpaRepository<Product, Long> {

    Optional<Product> findBySku(String sku);

    // Concurrency-safe upsert: try the update, insert only if nothing matched.
    @Modifying
    @Query(value = """
            UPDATE products SET product_name = :name, price = :price, updated_at = SYSUTCDATETIME()
            WHERE sku = :sku
            """, nativeQuery = true)
    int updateBySku(@Param("sku") String sku, @Param("name") String name,
                    @Param("price") BigDecimal price);

    // If you still want MERGE, hold the key range with HOLDLOCK and end with a semicolon:
    @Modifying
    @Query(value = """
            MERGE products WITH (HOLDLOCK) AS target
            USING (VALUES (:sku, :name, :price)) AS source (sku, name, price)
              ON target.sku = source.sku
            WHEN MATCHED THEN
              UPDATE SET target.product_name = source.name, target.price = source.price,
                         target.updated_at = SYSUTCDATETIME()
            WHEN NOT MATCHED THEN
              INSERT (sku, product_name, price, created_at)
              VALUES (source.sku, source.name, source.price, SYSUTCDATETIME());
            """, nativeQuery = true)
    void merge(@Param("sku") String sku, @Param("name") String name,
               @Param("price") BigDecimal price);

    // Paged native query: ORDER BY is mandatory, and countQuery must be supplied.
    @Query(value = "SELECT * FROM products WHERE active = 1 ORDER BY created_at DESC",
           countQuery = "SELECT COUNT(*) FROM products WHERE active = 1",
           nativeQuery = true)
    Page<Product> findActive(Pageable pageable);

    // Window functions, mapped to an interface projection instead of Object[]:
    interface CheapestInCategory { String getCategory(); String getName(); BigDecimal getPrice(); }

    @Query(value = """
            SELECT category, product_name AS name, price
            FROM (SELECT category, product_name, price,
                         ROW_NUMBER() OVER (PARTITION BY category ORDER BY price) AS rn
                  FROM products WHERE active = 1) ranked
            WHERE rn = 1
            """, nativeQuery = true)
    List<CheapestInCategory> findCheapestPerCategory();

    // Contains-search: pass the wildcards in from the caller, e.g. "%" + term + "%".
    @Query(value = "SELECT * FROM products WHERE product_name LIKE :pattern", nativeQuery = true)
    List<Product> searchByName(@Param("pattern") String pattern);
}

// FOR JSON PATH: avoid returning it through JPA. The driver splits long JSON results
// across multiple rows of about 2,000 characters, so a single String return breaks on
// larger data sets. Use JdbcTemplate and join the rows if you really need it:
//   String json = String.join("", jdbc.queryForList(sql, String.class));

Stored Procedures and Functions

Plenty of SQL Server shops keep business logic in procedures, and you will often have to call ones you did not write. Use @Procedure (or @NamedStoredProcedureQuery) for simple cases and StoredProcedureQuery when you need OUT parameters. Two things to know. First, @Procedure binds by the procedure's declared parameter names only if you annotate the method parameters with @Param, and the safest approach with SQL Server is positional registration, as below, since T-SQL parameter names carry an @ prefix that Hibernate does not add for you. Second, a procedure that returns a result set rather than OUT values should be called with createStoredProcedureQuery(...).getResultList(), and SET NOCOUNT ON at the top of every procedure avoids spurious update-count results confusing the driver.
Java
@Service
@Transactional
public class OrderProcedureService {

    @PersistenceContext
    private EntityManager em;

    public OrderResult processOrder(long orderId) {
        StoredProcedureQuery q = em.createStoredProcedureQuery("ProcessOrder")
            .registerStoredProcedureParameter(1, Long.class, ParameterMode.IN)
            .registerStoredProcedureParameter(2, String.class, ParameterMode.OUT)
            .registerStoredProcedureParameter(3, BigDecimal.class, ParameterMode.OUT)
            .setParameter(1, orderId);

        q.execute();
        return new OrderResult(orderId,
            (String) q.getOutputParameterValue(2),
            (BigDecimal) q.getOutputParameterValue(3));
    }

    // Table-valued function: it is just a native query.
    @SuppressWarnings("unchecked")
    public List<Object[]> productsInRange(BigDecimal min, BigDecimal max) {
        return em.createNativeQuery("""
                SELECT id, product_name, price
                FROM dbo.GetProductsByPriceRange(:min, :max)
                ORDER BY price
                """)
            .setParameter("min", min)
            .setParameter("max", max)
            .getResultList();
    }
}

-- V3__create_procedures.sql
CREATE PROCEDURE dbo.ProcessOrder
    @order_id BIGINT,
    @status   NVARCHAR(50)   OUTPUT,
    @total    DECIMAL(10,2)  OUTPUT
AS
BEGIN
    SET NOCOUNT ON;
    SELECT @total = SUM(quantity * unit_price) FROM dbo.order_items WHERE order_id = @order_id;
    SET @status = N'PROCESSED';
    UPDATE dbo.orders SET status = @status, total = @total WHERE id = @order_id;
END;
GO

CREATE FUNCTION dbo.GetProductsByPriceRange (@min_price DECIMAL(10,2), @max_price DECIMAL(10,2))
RETURNS TABLE AS
RETURN (SELECT id, product_name, price FROM dbo.products
        WHERE price BETWEEN @min_price AND @max_price AND active = 1);
GO

Flyway Migrations

Since Flyway 10, database support ships as separate modules, so SQL Server needs flyway-sqlserver on the classpath in addition to flyway-core; without it you get "Unsupported Database" at startup. Flyway understands the GO batch separator in SQL Server scripts, which you need for CREATE PROCEDURE and CREATE FUNCTION because they must be the only statement in their batch. If you use Flyway, leave ddl-auto at validate (or none) in every profile. Combining Flyway with create-drop gives you two tools fighting over the schema.
sql
<!-- pom.xml -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-sqlserver</artifactId>
</dependency>

# application.yml
spring:
  flyway:
    locations: classpath:db/migration/sqlserver
    schemas: dbo
    default-schema: dbo

-- V1__create_products.sql
CREATE SEQUENCE dbo.product_seq START WITH 1 INCREMENT BY 50;
GO

CREATE TABLE dbo.products (
    id            BIGINT           NOT NULL,
    version       BIGINT           NOT NULL DEFAULT 0,
    public_id     UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
    product_name  NVARCHAR(200)    NOT NULL,
    description   NVARCHAR(MAX)    NULL,
    price         DECIMAL(10,2)    NOT NULL,
    category      NVARCHAR(100)    NULL,
    sku           NVARCHAR(50)     NULL,
    active        BIT              NOT NULL DEFAULT 1,
    thumbnail     VARBINARY(MAX)   NULL,
    created_at    DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME(),
    updated_at    DATETIME2        NULL,
    CONSTRAINT PK_products PRIMARY KEY (id),
    CONSTRAINT UQ_products_sku UNIQUE (sku),
    CONSTRAINT CK_products_price CHECK (price >= 0)
);
GO

-- V4__indexes.sql
-- Covering index: category lookups can be answered without touching the base table.
CREATE NONCLUSTERED INDEX idx_products_category
    ON dbo.products (category) INCLUDE (product_name, price);

-- Filtered index: much smaller if most rows are inactive.
CREATE NONCLUSTERED INDEX idx_products_active_price
    ON dbo.products (price) WHERE active = 1;
GO

Docker and Testcontainers

SQL Server containers are heavy (they want about 2 GB of RAM and take 10 to 30 seconds to start), so share one container across the test run instead of one per class. On Spring Boot 3.1 and later, @ServiceConnection removes the DynamicPropertySource boilerplate. Note that the image tag matters: the container is Linux-only and does not run natively on Apple Silicon without Rosetta emulation enabled in Docker. For local development, the old SA_PASSWORD variable is deprecated; use MSSQL_SA_PASSWORD. The password must meet SQL Server's complexity rules or the container exits silently, so check docker logs first if it will not stay up.
Java
# docker-compose.yml
services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "YourStrong@Passw0rd"
      MSSQL_PID: Developer
    ports: ["1433:1433"]
    volumes: [sqlserver_data:/var/opt/mssql]
volumes:
  sqlserver_data:

<!-- pom.xml (test scope) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mssqlserver</artifactId>
    <scope>test</scope>
</dependency>

// Shared container, reused by every test class
public abstract class SqlServerTestBase {

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

    static { SQL_SERVER.start(); }
}

@SpringBootTest
class ProductRepositoryTest extends SqlServerTestBase {

    @Autowired ProductRepository repo;

    @Test
    void upsertUpdatesExistingRow() {
        repo.merge("SKU-001", "Widget", new BigDecimal("9.99"));
        repo.merge("SKU-001", "Widget Pro", new BigDecimal("19.99"));

        assertThat(repo.findBySku("SKU-001"))
            .get().extracting(Product::getName).isEqualTo("Widget Pro");
    }
}

Production Checklist

A short list of things that cause real incidents. Do not run the application as sa. Create a login with only the roles it needs (db_datareader, db_datawriter, and EXECUTE on specific procedures), and give the migration user DDL rights separately. Turn on READ_COMMITTED_SNAPSHOT for the database so readers stop blocking writers. It is a one-line change and removes most of the reasons people reach for NOLOCK. Expect deadlocks under load (error 1205) and treat them as retryable. Spring Retry or a small wrapper around the transaction, retrying on DeadlockLoserDataAccessException, is usually enough. Keep Hikari's max-lifetime shorter than any network device that drops idle connections, or you will see sporadic "connection reset" errors after quiet periods. Prefer Instant or LocalDateTime stored in DATETIME2 and normalise to UTC in the app. DATETIMEOFFSET exists but is rarely worth the extra mapping effort.
sql
-- One-time database setup
ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

-- Least-privilege application login
CREATE LOGIN app_user WITH PASSWORD = 'use-a-secret-manager';
USE mydb;
CREATE USER app_user FOR LOGIN app_user;
ALTER ROLE db_datareader ADD MEMBER app_user;
ALTER ROLE db_datawriter ADD MEMBER app_user;
GRANT EXECUTE ON dbo.ProcessOrder TO app_user;