Spring Boot
Oracle DB Integration
Connect a Spring Boot 3 application to Oracle Database with Spring Data JPA and Hibernate 6: driver setup, connection URLs, Flyway migrations, sequence-based IDs, Oracle-specific column types, MERGE, hierarchical queries, pagination, stored procedures and Testcontainers tests. It also covers Oracle quirks that surprise developers, such as empty strings becoming NULL, byte-based VARCHAR2 lengths and DDL that cannot be rolled back.
Dependencies
The Oracle JDBC driver is published on Maven Central, so you do not need Oracle's own Maven repository (that was only required for old driver versions). Use the ojdbc11 artifact for Java 11 and later, and leave the version out: Spring Boot manages a compatible one. Flyway needs two artifacts, because from Flyway 10 (Spring Boot 3.2+) each database has its own module; without flyway-database-oracle, startup fails with an unsupported-database error. HikariCP, Spring Boot's default pool, works well with Oracle. Oracle's own pool, UCP, is an alternative if you need features such as Fast Connection Failover on RAC; it is enabled by adding the ucp11 dependency and setting spring.datasource.type to oracle.ucp.jdbc.PoolDataSourceImpl.
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-oracle</artifactId>
</dependency>Configuration
The JDBC URL has three common forms, and mixing them up is the number one cause of connection errors. The "@//host:port/service" form connects by service name (use this for modern setups and pluggable databases such as XEPDB1). The "@host:port:SID" form connects by the older SID. A TNS alias or a cloud wallet is used for managed databases. You do not need driver-class-name or a Hibernate dialect: Spring Boot detects the driver from the URL and Hibernate 6 detects the Oracle dialect and version at startup. Hibernate is set to validate because Flyway owns the schema.
Keep the pool small to start; Oracle handles a modest number of busy connections better than a large idle pool. Firewalls and load balancers in front of Oracle often drop idle connections silently, so set Hikari's max-lifetime below that timeout (Hikari's default is 30 minutes) to retire connections before they go stale. The two Hibernate batch settings let inserts and updates for the same entity be grouped into JDBC batches, which works with sequence ids but not with identity columns.
yaml
spring:
datasource:
# Service name (recommended): @//host:port/service
url: jdbc:oracle:thin:@//localhost:1521/XEPDB1
# Older SID form: jdbc:oracle:thin:@localhost:1521:XE
# TNS alias or cloud wallet: jdbc:oracle:thin:@mydb_high?TNS_ADMIN=/path/to/wallet
username: appuser
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 20000
max-lifetime: 1500000 # 25 min: keep below any firewall idle timeout
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 25
order_inserts: true
order_updates: true
flyway:
enabled: true
locations: classpath:db/migrationSchema Migrations with Flyway
In Oracle a schema is a database user, so connect as the user that should own the tables and you do not need to name a schema anywhere in your entities. That user needs privileges to create what the migrations create (tables, sequences, procedures); missing ones show up as ORA-01031 or ORA-01950, covered at the end.
Four details are specific to Oracle. First, DDL statements commit implicitly, so a migration that fails halfway cannot be rolled back; keep each migration small and focused so a failure leaves little to clean up. Second, a stored procedure must end with a line containing only a slash so the script runner knows where the PL/SQL block finishes. Third, do not call COMMIT inside the procedure: it would commit the caller's whole Spring transaction, which defeats @Transactional. Fourth, VARCHAR2(200) means 200 bytes by default, so non-ASCII text can overflow it. Declare VARCHAR2(200 CHAR) to count characters instead.
The sequence increments by 20 to match allocationSize in the entity, and CACHE lets Oracle hand out values from memory instead of writing to the data dictionary each time.
sql
-- src/main/resources/db/migration/V1__create_sequence_and_products.sql
CREATE SEQUENCE PRODUCTS_SEQ START WITH 1 INCREMENT BY 20 CACHE 100 NOCYCLE;
CREATE TABLE PRODUCTS (
ID NUMBER(19) NOT NULL,
PRODUCT_NAME VARCHAR2(200 CHAR) NOT NULL,
DESCRIPTION CLOB,
CATEGORY VARCHAR2(100 CHAR),
PRICE NUMBER(10,2) NOT NULL,
ACTIVE NUMBER(1) DEFAULT 1 NOT NULL,
CREATED_AT TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
UPDATED_AT TIMESTAMP,
THUMBNAIL BLOB,
CONSTRAINT PK_PRODUCTS PRIMARY KEY (ID),
CONSTRAINT CK_PRODUCTS_ACTIVE CHECK (ACTIVE IN (0, 1))
);
CREATE INDEX IDX_PRODUCTS_CATEGORY ON PRODUCTS (CATEGORY);
CREATE INDEX IDX_PRODUCTS_ACTIVE_PRICE ON PRODUCTS (ACTIVE, PRICE);
CREATE TABLE CATEGORIES (
ID NUMBER(19) NOT NULL,
NAME VARCHAR2(100 CHAR) NOT NULL,
PARENT_ID NUMBER(19),
CONSTRAINT PK_CATEGORIES PRIMARY KEY (ID),
CONSTRAINT FK_CATEGORIES_PARENT FOREIGN KEY (PARENT_ID) REFERENCES CATEGORIES (ID)
);
-- src/main/resources/db/migration/V2__create_procedure.sql
CREATE OR REPLACE PROCEDURE DEACTIVATE_CATEGORY (
p_category IN VARCHAR2,
p_count OUT NUMBER
) AS
BEGIN
UPDATE PRODUCTS SET ACTIVE = 0, UPDATED_AT = SYSTIMESTAMP
WHERE CATEGORY = p_category AND ACTIVE = 1;
p_count := SQL%ROWCOUNT; -- no COMMIT here: the caller owns the transaction
END DEACTIVATE_CATEGORY;
/Entity Mapping and Oracle Type Quirks
Hibernate 6 handles most Oracle types without extra annotations. A String annotated with @Lob maps to CLOB and a byte[] to BLOB, and boolean maps to NUMBER(1) on Oracle versions before 23ai, so the custom converter many older tutorials show is not needed. LocalDateTime maps to TIMESTAMP; declare the columns as TIMESTAMP too, because a DATE column can fail schema validation (Oracle's DATE also stores a time part). Use sequences for ids: Hibernate fetches a block of ids per round trip and can batch inserts (identity columns work from Oracle 12c, but force one insert at a time).
Oracle also has behaviour that has no equivalent in most databases. It treats an empty string as NULL, so a NOT NULL column rejects "" with ORA-01400, and a query for name = '' never matches. Unquoted identifiers are stored in uppercase, which is why the table names here are uppercase. Reserved words such as ORDER, USER, LEVEL and COMMENT cannot be used as unquoted table or column names, which is why the examples use PRODUCT_NAME and similar. A VARCHAR2 holds at most 4000 bytes by default (not characters, which matters for non-ASCII text), so use a CLOB for longer text.
Java
@Entity
@Table(name = "PRODUCTS") // no schema attribute: the connected user owns the tables
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "products_seq")
@SequenceGenerator(name = "products_seq",
sequenceName = "PRODUCTS_SEQ",
allocationSize = 20) // must equal INCREMENT BY in the migration
private Long id;
@Column(name = "PRODUCT_NAME", nullable = false, length = 200)
private String name;
@Lob
@Column(name = "DESCRIPTION") // String + @Lob -> CLOB
private String description;
@Column(name = "CATEGORY", length = 100)
private String category;
@Column(name = "PRICE", nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "ACTIVE", nullable = false) // boolean -> NUMBER(1)
private boolean active = true;
@CreationTimestamp
@Column(name = "CREATED_AT", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "UPDATED_AT")
private LocalDateTime updatedAt;
@Lob
@Column(name = "THUMBNAIL") // byte[] + @Lob -> BLOB
private byte[] thumbnail;
// getters and setters omitted
}Oracle-Specific Native Queries
Use native queries for features JPQL does not cover. MERGE is Oracle's upsert: it updates the matching row or inserts a new one, and the SQL must use the real column names (PRODUCT_NAME, not the Java field name). CONNECT BY walks a hierarchy, here a category tree; ORDER SIBLINGS BY sorts within each level. Analytic functions such as RANK and SUM OVER add per-group figures without collapsing rows.
Two practical notes on results. Select only the columns you need in list queries: selecting p.* drags the CLOB and BLOB columns into every row, and only the hint query below does it because it maps full entities. And a List of Object[] gives you driver-dependent types: NUMBER columns can come back as BigDecimal, BigInteger or Integer, so convert through Number (for example ((Number) row[0]).longValue()) instead of casting to Long.
Optimizer hints are a last resort. They freeze a decision that Oracle's optimizer would otherwise revisit as data grows, so first check that table statistics are current and the index is suitable.
Java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying
@Transactional
@Query(value = """
MERGE INTO PRODUCTS p
USING (SELECT :id AS src_id, :name AS src_name, :price AS src_price FROM DUAL) src
ON (p.ID = src.src_id)
WHEN MATCHED THEN
UPDATE SET p.PRODUCT_NAME = src.src_name,
p.PRICE = src.src_price,
p.UPDATED_AT = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (ID, PRODUCT_NAME, PRICE)
VALUES (src.src_id, src.src_name, src.src_price)
""", nativeQuery = true)
void mergeProduct(@Param("id") Long id,
@Param("name") String name,
@Param("price") BigDecimal price);
// Hierarchy: roots first, children below their parent, siblings sorted by name
// Row layout: [ID, NAME, PARENT_ID, DEPTH] -- convert numbers through Number
@Query(value = """
SELECT ID, NAME, PARENT_ID, LEVEL AS DEPTH
FROM CATEGORIES
START WITH PARENT_ID IS NULL
CONNECT BY PRIOR ID = PARENT_ID
ORDER SIBLINGS BY NAME
""", nativeQuery = true)
List<Object[]> findCategoryHierarchy();
// Analytic functions: rank within category and category total
@Query(value = """
SELECT p.ID, p.PRODUCT_NAME, p.CATEGORY, p.PRICE,
RANK() OVER (PARTITION BY p.CATEGORY ORDER BY p.PRICE) AS PRICE_RANK,
SUM(p.PRICE) OVER (PARTITION BY p.CATEGORY) AS CATEGORY_TOTAL
FROM PRODUCTS p
WHERE p.ACTIVE = 1
""", nativeQuery = true)
List<Object[]> findWithAnalytics();
// Optimizer hint: use only after checking statistics and the execution plan.
// Maps full entities, so it needs every column.
@Query(value = """
SELECT /*+ INDEX(p IDX_PRODUCTS_CATEGORY) */ p.*
FROM PRODUCTS p
WHERE p.CATEGORY = :category AND p.ACTIVE = 1
ORDER BY p.PRICE, p.ID
""", nativeQuery = true)
List<Product> findByCategoryWithHint(@Param("category") String category);
}Pagination
Older articles describe pagination with nested ROWNUM subqueries. That is only needed for Oracle 11g and earlier. Hibernate 6 generates the standard OFFSET ... FETCH FIRST syntax for Oracle 12c and later, and Spring Data's Pageable works with derived queries and with native queries. Two rules matter: always include an ORDER BY, because without one the database may return rows in a different order on each page, and add a unique tie-breaker such as the id so rows with equal prices do not appear on two pages or on none. A native query returning a Page also needs a countQuery, because Spring Data cannot derive one from native SQL. For native queries, put the ordering in the SQL itself and pass a Pageable without a Sort; dynamic sorting on native queries behaves differently across Spring Data versions.
Java
@Repository
public interface ProductPagingRepository extends JpaRepository<Product, Long> {
// Derived query: Hibernate generates OFFSET / FETCH for Oracle 12c+
Page<Product> findByCategory(String category, Pageable pageable);
// Native query: needs ORDER BY (with a tie-breaker) and a countQuery
@Query(value = "SELECT * FROM PRODUCTS WHERE ACTIVE = 1 ORDER BY PRICE, ID",
countQuery = "SELECT COUNT(*) FROM PRODUCTS WHERE ACTIVE = 1",
nativeQuery = true)
Page<Product> findActivePage(Pageable pageable);
}
// Usage
Page<Product> page = repository.findActivePage(PageRequest.of(0, 20));Calling Stored Procedures
Spring Data JPA can call a procedure directly with @Procedure. Arguments are bound by position, and a procedure with a single OUT parameter, like DEACTIVATE_CATEGORY, returns that value from the method. When a procedure has several OUT parameters, use the EntityManager's StoredProcedureQuery and register each parameter by position. Run it inside a @Transactional method: the procedure then takes part in the same transaction as your other changes, which is also why the procedure itself must not commit. Without a surrounding transaction the connection runs in auto-commit mode and each call commits on its own.
Java
// Spring Data: IN parameter bound by position, single OUT value returned
@Procedure(procedureName = "DEACTIVATE_CATEGORY")
Integer deactivateCategory(String category);
// EntityManager: use when you need several OUT parameters
@Service
public class ProductMaintenanceService {
@PersistenceContext
private EntityManager em;
@Transactional
public int deactivate(String category) {
StoredProcedureQuery query = em.createStoredProcedureQuery("DEACTIVATE_CATEGORY")
.registerStoredProcedureParameter(1, String.class, ParameterMode.IN)
.registerStoredProcedureParameter(2, Integer.class, ParameterMode.OUT)
.setParameter(1, category);
query.execute();
return (Integer) query.getOutputParameterValue(2);
}
}Local Development with Docker
Oracle's official image requires a login to Oracle's container registry. The community gvenzl/oracle-xe image does not, and its APP_USER and APP_USER_PASSWORD variables create the application user for you, which is the user and schema the configuration above connects as. The first start takes noticeably longer than PostgreSQL or MySQL, so wait until the database reports ready before starting the application. The image ships a healthcheck script, which the compose file below uses so dependent services can wait for it. Oracle XE is free for development, but check Oracle's license terms before using any Oracle image in other contexts.
yaml
# docker-compose.yml
services:
oracle:
image: gvenzl/oracle-xe:21-slim-faststart
container_name: myapp-oracle
environment:
ORACLE_PASSWORD: sysadminpassword # password for SYS and SYSTEM
APP_USER: appuser
APP_USER_PASSWORD: apppassword
ports:
- "1521:1521"
volumes:
- oracle_data:/opt/oracle/oradata
healthcheck:
test: ["CMD", "healthcheck.sh"]
interval: 10s
timeout: 5s
retries: 30
volumes:
oracle_data:
# application-dev.yml
spring:
datasource:
url: jdbc:oracle:thin:@//localhost:1521/XEPDB1
username: appuser
password: apppasswordTesting with Testcontainers
Testing against a real Oracle catches problems an in-memory database hides, such as MERGE syntax, empty-string handling and type validation. The container starts slower than most databases, so keep Oracle tests focused on repository and migration behaviour and share one container across test classes where you can. @ServiceConnection (Spring Boot 3.1+) wires the datasource from the container, and Flyway runs your migrations against it on startup, so the test also proves that migrations and entities agree. Docker must be running. A @SpringBootTest is not rolled back, so clean the table before each test, otherwise data from one test leaks into the next. Note that the Testcontainers module name differs across versions (oracle-xe here, oracle-free in newer releases), so check the one matching your Testcontainers version.
Java
<!-- pom.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>oracle-xe</artifactId>
<scope>test</scope>
</dependency>
// Test class
@SpringBootTest
@Testcontainers
class ProductRepositoryOracleTest {
@Container
@ServiceConnection
static OracleContainer oracle = new OracleContainer("gvenzl/oracle-xe:21-slim-faststart");
@Autowired
private ProductRepository productRepository;
@BeforeEach
void cleanTable() {
productRepository.deleteAll(); // @SpringBootTest does not roll back
}
@Test
void mergeInsertsThenUpdates() {
productRepository.mergeProduct(1L, "Widget", new BigDecimal("9.99"));
productRepository.mergeProduct(1L, "Widget Pro", new BigDecimal("19.99"));
Product found = productRepository.findById(1L).orElseThrow();
assertThat(found.getName()).isEqualTo("Widget Pro");
assertThat(productRepository.count()).isEqualTo(1);
}
}Errors You Will Probably Hit
ORA-12514 ("listener does not currently know of service") almost always means the URL uses the service-name form but you supplied a SID. The reverse, the SID form with a service name, gives ORA-12505; compare the URL forms in the configuration section.
ORA-01017 (invalid username/password) with a correct password usually points to the wrong pluggable database; connect to XEPDB1, not the root container.
ORA-00942 (table or view does not exist) means the connected user cannot see the table: check you are connecting as the owner, or that the table was created in uppercase.
ORA-01400 (cannot insert NULL) after saving an empty string is Oracle treating "" as NULL; validate or convert blanks in your code.
ORA-12899 (value too large for column) with text that looks short is the byte-versus-character VARCHAR2 behaviour; declare columns with CHAR semantics.
ORA-01031 (insufficient privileges) or ORA-01950 (no privileges on tablespace) during a migration means the application user lacks a CREATE privilege or a tablespace quota; a database administrator needs to grant them.
ORA-00972 (identifier is too long) appears on Oracle versions before 12.2, which limit names to 30 bytes; shorten generated constraint and index names.
ORA-01882 (timezone region not found) can occur when the JVM or container uses a timezone name the driver does not know; setting the JVM option -Doracle.jdbc.timezoneAsRegion=false is the usual workaround.
If a Flyway migration fails partway, the DDL already executed stays applied and Flyway records the migration as failed. Repair the schema by hand, then run Flyway's repair command before starting the application again, or it will refuse to continue.
sql
-- Quick checks in SQL*Plus or any SQL client
SELECT SYS_CONTEXT('USERENV', 'CON_NAME') FROM DUAL; -- which container am I in?
SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL; -- which schema am I in?
SELECT table_name FROM user_tables; -- which tables do I own?
SELECT privilege FROM user_sys_privs; -- which system privileges do I have?
SELECT version, success FROM "flyway_schema_history"; -- failed migrations show success = 0FAQ
Do I need to set a Hibernate dialect for Oracle?
No. Hibernate 6 detects the Oracle dialect and database version from the connection. Remove any old hibernate.dialect setting unless you have a specific reason to pin it.
Should I use sequences or identity columns for ids?
Sequences. They let Hibernate allocate ids in blocks and batch inserts. Identity columns work on Oracle 12c and later but force one insert at a time.
Why does Oracle reject an empty string in a NOT NULL column?
Oracle stores an empty string as NULL. Validate input in your application, or allow NULL where blank is a legitimate value.
How do I connect to a pluggable database or Oracle Cloud?
Use the service-name URL form (@//host:port/service) for a pluggable database. For Oracle Cloud Autonomous Database, use the TNS alias with the wallet directory in TNS_ADMIN.
Can I roll back a failed Flyway migration on Oracle?
No. Oracle commits DDL implicitly, so statements that already ran stay applied. Keep migrations small, and repair by hand before running Flyway's repair command.
Should I use HikariCP or Oracle UCP?
HikariCP is the default and is enough for most applications. Choose UCP when you need Oracle-specific features such as Fast Connection Failover on RAC.