Guide: Write Ebean Queries with Query Beans
August 6, 2026 · View on GitHub
Purpose
This guide gives step-by-step instructions for AI agents and developers to write application queries using Ebean query beans.
Use this guide when the project already has Ebean configured and you need to:
- add a repository/service query
- replace string-based ORM queries with type-safe query beans
- tune what data is fetched to avoid over-fetching or N+1 issues
- return DTO projections for list screens or API responses
The default recommendation is:
- Prefer query beans first
- Prefer entity queries for domain logic
- For read-only entity graphs, prefer
setUnmodifiable(true) - Prefer DTO projection for summary/read-model use cases
- Only drop to raw SQL when the ORM query cannot express the requirement cleanly
Prerequisites
- The project already uses Ebean ORM
- Query bean generation is configured (for Maven this usually means
querybean-generatoris registered as an annotation processor) - Entity beans already exist
- A compile/build has run successfully since the last entity model change
If query beans are not yet configured, first follow:
add-ebean-postgres-maven-pom.md
Step 1 - Verify the generated Q* query bean exists
For each entity bean, Ebean generates a query bean with the same name prefixed
with Q.
Examples:
Customer->QCustomerOrder->QOrderContact->QContact
Import the generated type from the query bean package:
import org.example.domain.query.QCustomer;
If the Q* type does not exist or the IDE cannot resolve it:
- Confirm the entity compiled successfully
- Run a normal project compile/build
- If the entity was renamed or moved, run a full rebuild rather than relying on incremental compilation
Important caveat - entity rename
After refactoring an entity name, old generated query beans can remain on disk
until the next full build. If both old and new Q* types appear to exist, do a
clean rebuild before editing application queries.
Step 2 - Choose the terminal query method before writing predicates
Decide what the caller actually needs. This determines the terminal method and often the right query shape.
| Need | Preferred method | Notes |
|---|---|---|
| Check if at least one row exists | exists() | Cheapest choice for boolean existence checks |
| Load exactly one row by ID or unique key | findOne() | Only use when the predicate is truly unique |
| Load a list of entity beans | findList() | Default for list screens and domain logic |
| Stream rows, usually to map into another type | findStream() | For large/unbounded results streamed from the JDBC cursor; close via try-with-resources. For small/bounded results prefer findList().stream() |
| Count matching rows | findCount() | Prefer over loading entities just to count |
| Load a page plus optional total row count | findPagedList() | Use when the caller needs pagination metadata |
| Return DTO/read-model rows | asDto(...).findList() | Prefer this over partially loaded entities for API/view models |
Example - existence check
boolean alreadyUsed = new QCustomer()
.email.equalTo(email)
.exists();
Example - unique lookup
Customer customer = new QCustomer()
.email.equalTo(email)
.findOne();
Do not use findOne() for predicates that can match multiple rows.
Example - stream and map to another type
Choose based on result size and how you consume it:
findList().stream()— executes the query, materialises the rows, releases the connection, then streams over an in-memory list. No open database resources and no try-with-resources needed. Prefer this for small or bounded results (e.g. when you applysetMaxRows) that you collect anyway.findStream()— streams rows directly from the JDBC cursor, holding a connection (and an implicit transaction) open for the whole lifetime of the stream pipeline. It must be closed with try-with-resources. Prefer it when the result may be large, when you want constant memory, or when you want to short-circuit (limit,findFirst,takeWhile) without loading everything.
// small, bounded result fully collected -> findList().stream()
List<PendingPlan> pending = new QCaptureRequest()
.collectedAt.isNull()
.orderBy().requestedAt.asc()
.findList()
.stream()
.map(r -> new PendingPlan(r.app().getName(), r.hash()))
.toList();
// large/unbounded result streamed from the cursor -> findStream() + try-with-resources
try (Stream<Customer> stream = new QCustomer()
.status.equalTo(Status.NEW)
.findStream()) {
stream
.map(...)
.forEach(...);
}
For processing large results one bean at a time, findEach() is often the
simplest choice because it closes the underlying resources automatically.
Step 3 - Build predicates by traversing properties and associations
With query beans, write predicates directly against properties. When you traverse an association, Ebean adds the necessary joins automatically.
Example - root property predicates
List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.name.istartsWith("rob")
.findList();
Example - association traversal
List<Customer> customers = new QCustomer()
.billingAddress.city.equalTo("Auckland")
.findList();
Example - collection predicate
List<Customer> customers = new QCustomer()
.contacts.isEmpty()
.findList();
Optional predicates - prefer conditional helpers over if blocks
When a filter is driven by a nullable/optional parameter, use the built-in
conditional helpers instead of wrapping predicates in if blocks. The query
stays fluent and reads top-to-bottom, and no predicate is added when the value
is absent.
| Helper | Adds predicate when | Resulting SQL |
|---|---|---|
eqIfPresent(v) | v != null | prop = ? |
eqIfNotBlank(v) (String) | v non-null and not blank (value is trimmed) | prop = ? |
eqOrNull(v) | always | (prop = ? or prop is null) |
inOrEmpty(coll) | coll non-empty | prop in (...) (no predicate when empty) |
likeIfPresent / ilikeIfPresent / startsWithIfPresent / istartsWithIfPresent / containsIfPresent / icontainsIfPresent (String) | v != null | the match expression |
// Instead of building the query with if blocks:
QCustomer q = new QCustomer();
if (name != null && !name.isBlank()) {
q.name.eq(name.trim());
}
if (status != null) {
q.status.eq(status);
}
List<Customer> customers = q.findList();
// Prefer the conditional helpers:
List<Customer> customers = new QCustomer()
.name.eqIfNotBlank(name)
.status.eqIfPresent(status)
.findList();
Use eqOrNull(v) when a null column value should also match - for example an
"any environment" row stored with env_id is null should surface under any env
filter - instead of a hand-rolled or()/eq()/isNull()/endOr() block:
List<CaptureRequest> rows = new QCaptureRequest()
.env.name.eqOrNull(envFilter) // env_name = ? or env_name is null
.findList();
Agent rule
When adding a new query:
- Start from the root entity that the caller wants back
- Add predicates with query bean properties
- Traverse relationships instead of writing manual join SQL
- Keep property references type-safe; avoid string property names unless the API specifically requires them
- For optional filters, reach for
eqIfPresent/eqIfNotBlank/inOrEmptybefore writing anif (param != null)block, and useeqOrNullinstead of a manualor()/eq()/isNull()/endOr()when the intent is "match this value or a null column"
Step 4 - Add ordering, limits, and pagination deliberately
Do not leave list queries unordered unless the call site truly does not care. For UI lists, APIs, and background jobs, explicit ordering is usually better.
Example - ordered list with limit
List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().name.asc()
.setMaxRows(50)
.findList();
Example - offset/limit pagination
List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().id.asc()
.setFirstRow(offset)
.setMaxRows(pageSize)
.findList();
Example - paged list with total count
PagedList<Customer> page = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().id.asc()
.setFirstRow(offset)
.setMaxRows(pageSize)
.findPagedList();
page.loadRowCount();
List<Customer> customers = page.getList();
int totalRowCount = page.getTotalRowCount();
Agent rule
- Use
findList()when the caller only needs rows - Use
findPagedList()when the caller also needs page metadata or total counts - Pair pagination with a stable
orderBy()so page boundaries stay predictable
Step 5 - Control fetched data with select() and fetch()
By default, entity queries can load more of the object graph than the caller
actually needs. Use select() and fetch() to control the root and association
properties that are loaded.
Root properties with select()
Use select() to define which properties should be fetched on the root entity.
Associated bean properties with fetch()
Use fetch() to define what should be fetched on associated paths.
Example - partial entity query
private static final QCustomer CUST = QCustomer.alias();
private static final QContact CONT = QContact.alias();
List<Customer> customers = new QCustomer()
.select(CUST.name, CUST.status, CUST.whenCreated)
.contacts.fetch(CONT.email)
.name.istartsWith("rob")
.findList();
In this example:
select(...)tunes the rootCustomerpropertiescontacts.fetch(...)tunes the associatedContactproperties- the query still returns
Customerentity beans
Agent rules for partial entity queries
- Only use
select()/fetch()when you know what the caller will read next - Do not treat partially loaded entities like fully populated API DTOs
- If the caller only needs summary fields, prefer a DTO projection instead
Step 6 - Use setUnmodifiable(true) for read-only entity graphs
setUnmodifiable(true) turns the returned object graph into an unmodifiable,
read-only graph.
This means:
- setters cannot mutate returned beans
- associated collections are unmodifiable
- lazy loading is disabled
- accessing an unloaded property throws
LazyInitialisationException - the query uses
PersistenceContextScope.QUERY
Example - read-only entity graph
private static final QCustomer CUST = QCustomer.alias();
private static final QContact CONT = QContact.alias();
List<Customer> customers = new QCustomer()
.select(CUST.name, CUST.status, CUST.whenCreated)
.contacts.fetch(CONT.email)
.status.equalTo(Customer.Status.ACTIVE)
.setUnmodifiable(true)
.findList();
When to prefer setUnmodifiable(true)
Use it when the result is meant to be read-only, such as:
- service/query methods returning entity graphs for display or serialization
- query results you want the application to treat as immutable
- cached query results or other shared read models backed by entity graphs
- partial entity graphs where you want accidental lazy loading to fail fast
When not to use it
Do not use setUnmodifiable(true) when the caller will:
- modify the beans and save them later
- rely on lazy loading of associations or unloaded scalar properties
- treat the result as a working persistence model rather than a read-only view
Agent rule
If you are returning entity beans for read-only use, setUnmodifiable(true)
should be the default recommendation. If the caller needs a mutable model or a
serialized summary shape, choose mutable entities or DTO projection instead.
If you need cached assoc-one references for unmodifiable graphs, see Immutable bean cache for read-only references.
Step 7 - Use fetchQuery() for to-many paths and FetchGroup for reusable query shapes
Ebean applies important SQL rules when translating ORM queries:
- It does not generate SQL cartesian products
- It honors
maxRowsin SQL
This means to-many paths often need special handling.
Use fetchQuery() when:
- the query includes a
OneToManyorManyToManypath - the query includes
setMaxRows(...) - the query loads multiple to-many paths
- you want the query shape to make the secondary-query behavior explicit
Example - explicit secondary queries for to-many paths
private static final QCustomer CUST = QCustomer.alias();
List<Order> orders = new QOrder()
.customer.fetch(CUST.name)
.lines.fetchQuery()
.shipments.fetchQuery()
.status.equalTo(Order.Status.NEW)
.setMaxRows(100)
.findList();
Use FetchGroup when:
- the same fetch shape is reused in multiple places
- you want to separate predicate logic from fetch-shape tuning
- you want an immutable, static query-shape definition
Example - reusable fetch group
private static final QCustomer CUST = QCustomer.alias();
private static final FetchGroup<Customer> CUSTOMER_SUMMARY =
QCustomer.forFetchGroup()
.select(CUST.name, CUST.status, CUST.whenCreated)
.billingAddress.fetch()
.buildFetchGroup();
List<Customer> customers = new QCustomer()
.select(CUSTOMER_SUMMARY)
.status.equalTo(Customer.Status.ACTIVE)
.findList();
Agent rule
If the caller needs multiple to-many paths or a paged query, be suspicious of a
plain fetch(...) on those paths. fetchQuery() is often the safer default.
@OneToOne(mappedBy=...) is EAGER by default — mark it LAZY
The non-owning side of a @OneToOne (the side with mappedBy) defaults to
FetchType.EAGER per JPA, same as @ManyToOne. Unlike a @ManyToOne
reference (which is FK-only until .fetch()'d), Ebean's default select for an
EAGER @OneToOne(mappedBy=...) still adds a left join to the target table
on every query for the owning entity — even a plain findById() — because
there is no local FK column to use as a lazy reference; the only way to know
the associated row exists is to join to it.
If that association is rarely needed (e.g. a rarely-read child/detail table), this join executes on every load of the parent, including in hot-path list queries, and can dominate query cost as more such associations accumulate.
Always set fetch = FetchType.LAZY on @OneToOne(mappedBy=...)
associations unless the association is genuinely needed on (almost) every
load:
@OneToOne(mappedBy = "device", fetch = FetchType.LAZY)
private SensorBoard sensorBoard;
This correctly excludes the join from Ebean's default select clause (verified
for FK-based, non-shared-primary-key @OneToOne relationships — the common
case). Callers that do need the association can still .fetch("sensorBoard")
explicitly on the query bean.
Caveat: the exclusion is driven by Ebean's default-select-clause
mechanism. It is bypassed if the query has already been switched into an
"all properties" mode by something other than the deploy-time
FetchType.LAZY/EAGER metadata (for example, an active AutoTune profile
that supplies its own tuned property set). Confirm the join is actually gone
by checking generated SQL (LoggedSql in tests, or query logging) after
making this change — don't assume it's excluded from the annotation alone.
Agent rule
Default new @OneToOne(mappedBy=...) fields to fetch = FetchType.LAZY
unless there's a clear reason the association is needed on every load. This
is a one-line, low-risk change that avoids an always-on join.
Step 8 - Use DTO projection when the caller does not need entity beans
For list screens, API summaries, exports, or read-model views, the caller often
does not need managed entity beans. In those cases, project directly to a
DTO using asDto(...).
Example - DTO projection with query beans
import static org.example.domain.query.QCustomer.Alias.id;
import static org.example.domain.query.QCustomer.Alias.name;
public record CustomerSummary(long id, String name) {}
List<CustomerSummary> summaries = new QCustomer()
.select(id, name)
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().name.asc()
.asDto(CustomerSummary.class)
.findList();
Prefer DTO projection when:
- the caller will serialize the result directly
- only a subset of fields is needed
- the result is not going to be updated and saved back as an entity
- the query contains formulas or aggregation intended for a read model
asDto(...) maps a flat, single-row result. If the target DTO itself needs nested
DTO fields (ToOne/ToMany) mirroring part of the entity graph, use
mapTo(Dto.class) instead — see
Mapping entity graphs to DTOs.
Step 9 - Only fall back to raw SQL when the ORM query is not a good fit
Prefer the following order:
- Query bean query
- Query bean query +
asDto(...) database.findDto(...)or DTO query- Native SQL /
SqlQuery/RawSql
Typical reasons to use raw SQL
- vendor-specific SQL that query beans do not express well
- advanced aggregation or database functions
- hand-tuned reporting queries
- stored procedures or raw JDBC workflows
Do not jump to raw SQL just because the query joins multiple tables. Query beans already handle ordinary relationship traversal well.
Using RawSql with query beans
RawSql is not limited to the plain Query<T> API - it also works with a
generated query bean, giving type-safe where()/having() expressions over
hand-written SQL. Every generated query bean exposes setRawSql(...):
RawSql rawSql = RawSqlBuilder.parse("select id, name, status from customer")
.columnMapping("id", "id")
.columnMapping("name", "name")
.columnMapping("status", "status")
.create();
List<Customer> customers = new QCustomer()
.setRawSql(rawSql)
.status.equalTo(Customer.Status.ACTIVE) // typed expression, injected into the parsed WHERE clause
.findList();
For the full guide to building RawSql - including unparsed(),
withPlaceholders() for CTEs/window functions, the ${where} / ${andWhere}
/ ${having} / ${andHaving} placeholder reference, and column mapping - see
Using RawSql with Ebean.
Common anti-patterns
Anti-pattern 1 - Using raw SQL first
Avoid:
List<Customer> customers = database.findNative(Customer.class,
"select c.* from customer c join address a on a.id = c.billing_address_id where a.city = ?")
.setParameter(1, city)
.findList();
Prefer:
List<Customer> customers = new QCustomer()
.billingAddress.city.equalTo(city)
.findList();
Anti-pattern 2 - Using findOne() on a non-unique predicate
Avoid:
Customer customer = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.findOne();
Why: Many rows can match; this is not a unique lookup.
Anti-pattern 3 - Returning partially loaded entities as API models
If the caller only needs summary fields, return a DTO instead of partially loaded entities that might later trigger more loading or confuse serializers.
Anti-pattern 4 - Returning mutable entity graphs for read-only use
If the caller is only meant to read the result, prefer setUnmodifiable(true)
so accidental setter calls, collection mutation, and lazy loading fail fast.
Anti-pattern 5 - Fetching every relationship "just in case"
Do not eagerly fetch large object graphs unless the immediate caller will use them. Query tuning is part of the job.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot resolve symbol QCustomer | Query bean generation not configured or build not run | Check the annotation processor and run a build |
Old Q* class still appears after entity rename | Stale generated source/class output | Run a clean rebuild |
findOne() fails because multiple rows match | Predicate is not unique | Use findList() or tighten the predicate |
| Returned entities only have some fields loaded | select() or FetchGroup limited the query shape | Add the required fields or switch to DTO projection |
| Setter calls or collection mutation fail on query results | setUnmodifiable(true) returned a read-only graph | Remove setUnmodifiable(true) or treat the result as read-only |
Accessing an unloaded property throws LazyInitialisationException | setUnmodifiable(true) disables lazy loading | Fetch the property up front or use DTO projection |
| Ebean executes secondary queries for a to-many path | ORM rules avoided cartesian product or honored maxRows | This is expected; use fetchQuery() explicitly when appropriate |
Summary workflow for AI agents
When asked to add or modify an Ebean query:
- Verify the relevant
Q*type exists - Choose the terminal method first (
exists,findOne,findList,findPagedList,asDto) - Add predicates with query bean properties and association traversal
- Add explicit ordering and pagination if relevant
- If the result is read-only entity data, consider
setUnmodifiable(true) - Tune the fetch shape with
select()/fetch()/fetchQuery()/FetchGroup - Prefer DTO projection for read models and serialized responses
- Only use raw SQL if the ORM query is genuinely the wrong tool