Guide: Persist Changes and Manage Transactions with Ebean
May 1, 2026 ยท View on GitHub
Purpose
This guide gives step-by-step instructions for AI agents and developers to save, update, delete, and batch changes with Ebean while choosing the correct transaction boundary.
Use this guide when you need to:
- create a new entity row
- update one or more existing rows
- delete rows safely
- decide between implicit transactions,
@Transactional, and explicit transactions - batch or bulk-write many rows efficiently
The default recommendation is:
- Choose the correct persistence operation first
- Use implicit transactions for a single isolated write
- Use
@Transactionalfor multi-step application workflows - Use explicit transactions only when you need explicit control
- Use bulk update or batching for large write sets
Prerequisites
- The project already uses Ebean ORM
- Entity beans and database configuration already exist
- You know which
Databaseis being used (DB.getDefault()or a named database)
If the project is not yet configured, first follow:
Step 1 - Choose the correct persistence operation before editing code
Do not start with database.save(...) by habit. First decide what kind of change the
caller is making.
| Need | Preferred API | Use when |
|---|---|---|
| Insert a bean that is definitely new | database.insert(bean) | New-create flow, seed data, fixture setup |
| Save a bean that may be new or existing | database.save(bean) | Common default when bean state determines insert vs update |
| Update a bean that is definitely existing | database.update(bean) | Existing row should be updated only |
| Delete one bean | database.delete(bean) | Remove a loaded entity bean |
| Update many rows without loading beans | database.update(...) or query.asUpdate() | Set-based write, not per-row business logic |
| Delete many rows without loading beans | bulk update/delete API or database.sqlUpdate(...) | Set-based deletion |
Agent rule
Choose the operation that matches intent:
- known new row ->
insert - known existing row ->
update - uncertain/new-or-existing ->
save - many rows -> bulk update/delete, not a loop of individual saves
Style note
Use a Database instance for all persistence operations: database.save(bean),
database.insert(bean), database.update(bean), database.delete(bean).
Inject the Database bean or obtain it via DB.getDefault(). Avoid using the
static DB.* convenience methods.
Step 2 - Persist single-bean changes with the correct API
Example - insert a known new bean
Customer customer = new Customer();
customer.setName("Rob");
customer.setEmail("rob@example.com");
database.insert(customer);
Example - update an existing bean
Customer customer = new QCustomer()
.id.equalTo(customerId)
.findOne();
customer.setStatus(Customer.Status.ACTIVE);
database.update(customer);
When to prefer insert() over save()
Use insert() when the code is creating a brand new row and should fail if the
operation does not behave like an insert.
When to prefer update() over save()
Use update() when the bean is definitely existing and the method should not
silently behave like an insert.
Step 3 - Check cascade mappings before assuming related beans will persist or delete
Ebean follows cascade rules defined on mapping annotations such as
@OneToMany, @OneToOne, @ManyToOne, and @ManyToMany.
The default is no cascade.
Example
@Entity
public class Order {
@ManyToOne
private Customer customer; // no cascade by default
@OneToMany(cascade = CascadeType.ALL)
private List<OrderDetail> details; // save + delete cascade
}
database.save(order);
With the mapping above:
detailsare cascadedcustomeris not cascaded
Agent rules for cascades
- Inspect the mapping before writing save/delete logic
- Do not assume
@ManyToOnecascades - Avoid adding cascade to shared parent references unless ownership is truly intended
- If a relationship should not cascade, save/delete related beans explicitly
Step 4 - Let Ebean use an implicit transaction for a single isolated write
If the method performs one isolated persistence operation, Ebean can manage the transaction implicitly.
Good fit for implicit transaction
Customer customer = new QCustomer()
.id.equalTo(customerId)
.findOne();
customer.setStatus(Customer.Status.INACTIVE);
database.save(customer);
Good fit
- one save
- one update
- one delete
- small helper method with a single write
Poor fit
- multiple writes that must commit or roll back together
- query + save + save workflow
- any method where later failure must roll back earlier writes
Important
Queries also use implicit transactions when needed. You generally do not need to wrap ordinary read queries in an explicit transaction "just in case".
Step 5 - Use @Transactional for multi-step service workflows
When multiple Ebean operations belong to one unit of work, use
@Transactional.
Example - service method
import io.ebean.annotation.Transactional;
@Transactional
public void shipOrder(long orderId) {
Order order = new QOrder()
.id.equalTo(orderId)
.findOne();
order.setStatus(Order.Status.SHIPPED);
database.save(order);
Shipment shipment = new Shipment(order, Instant.now());
database.insert(shipment);
}
All database work inside the method runs in one transaction and commits only if the method completes successfully.
Use Transaction.current() only when needed
If the method needs access to the current transaction itself:
Transaction txn = Transaction.current();
Do this only for transaction-specific behavior such as comments, savepoints, or other advanced control. Do not fetch the current transaction if the method does not need it.
Agent rules for @Transactional
- Put it on application/service workflow methods, not everywhere by default
- Keep the transaction focused on database work
- Avoid remote HTTP calls, message publishing, or long-running CPU work inside the transaction if those can be moved outside
Named database note
If the method uses a non-default database, obtain that Database instance via
DB.byName("...") and consistently use that database for both queries and
writes.
Step 6 - Use beginTransaction() when you need explicit control
Use an explicit transaction when you need manual commit(), batching, explicit
flush, savepoints, or other low-level transaction control.
Example - explicit transaction with try-with-resources
try (Transaction txn = database.beginTransaction()) {
Order order = new QOrder()
.id.equalTo(orderId)
.findOne();
order.cancel();
database.save(order);
AuditLog auditLog = new AuditLog("order-cancelled", orderId);
database.insert(auditLog);
txn.commit();
}
If commit() is not reached, closing the transaction rolls it back.
Useful explicit controls
txn.commit()- commit current worktxn.setRollbackOnly()- force rollback-only behaviortxn.flush()- push batched statements to the database now
Agent rule
Prefer @Transactional unless explicit transaction control is actually needed.
Do not use beginTransaction() only because it feels "safer".
Step 7 - Use createTransaction() only for non-thread-local transaction handling
createTransaction() creates a transaction that is not placed into the
thread-local scope. This is a specialized tool.
Use it when:
- the transaction will be passed explicitly
- you need more than one transaction in the same thread
- you are coordinating work across threads or lower-level APIs
Example - explicit transaction passed to query and save
Database database = DB.getDefault();
try (Transaction txn = database.createTransaction()) {
Customer customer = new QCustomer(txn)
.email.equalTo(email)
.findOne();
customer.setInactive(true);
database.save(customer, txn);
txn.commit();
}
Agent rule
If you are not deliberately bypassing thread-local transaction scope, do not
use createTransaction(). Most service code should use @Transactional or
beginTransaction().
Step 8 - Use bulk update/delete or JDBC batch for many-row writes
Loops of database.save(...) are often the wrong tool for large write sets.
Prefer bulk update for set-based changes
If the update can be expressed as "change all rows matching this predicate", perform one bulk update instead of loading and saving each bean.
Example - bulk update with query beans
var cust = QCustomer.alias();
int rows = new QCustomer()
.status.equalTo(Customer.Status.NEW)
.asUpdate()
.set(cust.status, Customer.Status.ACTIVE)
.update();
Example - bulk update with database.update(...)
int rows = database.update(Customer.class)
.set("status", Customer.Status.ACTIVE)
.where()
.eq("status", Customer.Status.NEW)
.update();
Prefer JDBC batch for many individual inserts/updates
If each row has different values and must still go through per-bean persistence, use batching.
Database database = DB.getDefault();
try (Transaction txn = database.beginTransaction()) {
txn.setBatchMode(true);
txn.setBatchSize(100);
txn.setGetGeneratedKeys(false);
for (Customer customer : customersToInsert) {
database.insert(customer, txn);
}
txn.commit();
}
Alternative - annotation-driven batching
@Transactional(batchSize = 50)
public void importCustomers(List<Customer> customers) {
for (Customer customer : customers) {
database.insert(customer);
}
}
Batch caveats
- Executing a query inside a batched transaction can flush the batch
- Mixing bean persistence and
SqlUpdatecan also flush the batch - Accessing generated/unloaded properties on batched beans can flush the batch
If the workflow depends on delayed flushing, review the batch-flush rules before adding more queries inside the same transaction.
Common anti-patterns
Anti-pattern 1 - Saving many rows one by one without batch or bulk update
If you are changing hundreds or thousands of rows, first ask whether it should be a bulk update or a batched transaction.
Anti-pattern 2 - Assuming child beans cascade automatically
Cascade is not automatic. Inspect the mapping first.
Anti-pattern 3 - Wrapping external calls inside the database transaction
Do not keep transactions open while waiting on HTTP calls, queues, or other slow external systems unless the design genuinely requires it.
Anti-pattern 4 - Using createTransaction() for ordinary service code
Most service code should not bypass thread-local transaction handling.
Anti-pattern 5 - Using save() when you really need insert() or update()
If operation intent matters, choose the more specific API.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Child beans were not saved or deleted | Missing cascade mapping | Inspect annotations and add explicit save/delete or the correct cascade |
| Earlier writes committed even though later work failed | The whole workflow was not inside one transaction | Wrap the unit of work in @Transactional or an explicit transaction |
OptimisticLockException on update/delete | Concurrent modification or stale version | Re-fetch, merge, or handle concurrency explicitly |
| Batch writes flush earlier than expected | Query, mixed SQL, or property access triggered flush | Review batch flush rules and transaction flow |
| Explicit transaction example does not affect the expected database | Mixed default DB and named DB usage | Use the same Database instance consistently for query and write |
Summary workflow for AI agents
When asked to add persistence logic:
- Choose
insert,save,update,delete, or bulk update based on intent - Inspect cascade mappings before assuming related beans will persist/delete
- Use implicit transactions for one isolated write
- Use
@Transactionalfor multi-step units of work - Use
beginTransaction()only when explicit transaction control is needed - Use
createTransaction()only for explicit, non-thread-local handling - Use bulk update or batching for large write sets