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
- 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:
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 |
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
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.
Customer customer = new Customer();
customer.setName("Rob");
customer.setEmail("rob@example.com");
database.insert(customer);Customer customer = new QCustomer()
.id.equalTo(customerId)
.findOne();
customer.setStatus(Customer.Status.ACTIVE);
database.update(customer);Use insert() when the code is creating a brand new row and should fail if the
operation does not behave like an insert.
Use update() when the bean is definitely existing and the method should not
silently behave like an insert.
Ebean follows cascade rules defined on mapping annotations such as
@OneToMany, @OneToOne, @ManyToOne, and @ManyToMany.
The default is no cascade.
@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
- 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
If the method performs one isolated persistence operation, Ebean can manage the transaction implicitly.
Customer customer = new QCustomer()
.id.equalTo(customerId)
.findOne();
customer.setStatus(Customer.Status.INACTIVE);
database.save(customer);- one save
- one update
- one delete
- small helper method with a single write
- multiple writes that must commit or roll back together
- query + save + save workflow
- any method where later failure must roll back earlier writes
Queries also use implicit transactions when needed. You generally do not need to wrap ordinary read queries in an explicit transaction "just in case".
When multiple Ebean operations belong to one unit of work, use
@Transactional.
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.
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.
- 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
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.
Use an explicit transaction when you need manual commit(), batching, explicit
flush, savepoints, or other low-level transaction control.
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.
txn.commit()- commit current worktxn.setRollbackOnly()- force rollback-only behaviortxn.flush()- push batched statements to the database now
Prefer @Transactional unless explicit transaction control is actually needed.
Do not use beginTransaction() only because it feels "safer".
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
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();
}If you are not deliberately bypassing thread-local transaction scope, do not
use createTransaction(). Most service code should use @Transactional or
beginTransaction().
Loops of database.save(...) are often the wrong tool for large write sets.
If the update can be expressed as "change all rows matching this predicate", perform one bulk update instead of loading and saving each bean.
var cust = QCustomer.alias();
int rows = new QCustomer()
.status.equalTo(Customer.Status.NEW)
.asUpdate()
.set(cust.status, Customer.Status.ACTIVE)
.update();int rows = database.update(Customer.class)
.set("status", Customer.Status.ACTIVE)
.where()
.eq("status", Customer.Status.NEW)
.update();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();
}@Transactional(batchSize = 50)
public void importCustomers(List<Customer> customers) {
for (Customer customer : customers) {
database.insert(customer);
}
}- 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.
If you are changing hundreds or thousands of rows, first ask whether it should be a bulk update or a batched transaction.
Cascade is not automatic. Inspect the mapping first.
Do not keep transactions open while waiting on HTTP calls, queues, or other slow external systems unless the design genuinely requires it.
Most service code should not bypass thread-local transaction handling.
If operation intent matters, choose the more specific API.
| 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 |
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