DEV Community

Vaishnavi Agrawal
Vaishnavi Agrawal

Posted on Originally published at vaishnaviagrawal1.substack.com

Derived Queries or Query Annotations in Spring Data JPA

Welcome back. The last few pieces about my Purchase Decision API were fairly heavy, from error handling to an AI feature that is allowed to fail to a request that arrived as the wrong method. This one is smaller and more practical: the decision you make every time you add a method to a repository, usually without noticing you made it.

findByEmail feels like magic. findByUserUserIdAndVerdictAndCreatedAtBetweenOrderByScoreDesc feels like a warning.

Both are the same feature. Spring Data JPA reads the name of a method on your repository interface and writes the query for you. You never write the implementation, and for the first few methods it is genuinely delightful.

Then a requirement arrives that the name can technically express. So you express it. Nothing complains, the tests pass, and you have quietly crossed a line nobody marked.

This article is about where that line is, and what it costs to cross it.

What a derived query actually is

It is not magic, and it is not happening at call time. When your application starts, Spring Data parses the method name into a query.

The vocabulary is a real grammar with a finite set of keywords. Subjects like findBy, countBy, existsBy and deleteBy. Predicates like And, Or, Between, LessThan, After, Containing, IgnoreCase. Modifiers like OrderBy, and First or Top for limiting. The full list is in the Spring Data JPA reference, and it is worth reading once, so you know what is there before reaching for something heavier.

Most repositories start out looking like this:

public interface UserRepository extends JpaRepository<User, UUID> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}
Enter fullscreen mode Exit fullscreen mode

Two methods, no implementation, and both read like sentences. This is the style at its best.

The filename problem

A good filename is invoice.pdf. A bad one is invoice-march-2026-final-v2-approved-by-finance-REVISED.pdf. Nobody sat down and decided to write the second one. It grew one qualifier at a time, every single step was reasonable, and the result is a document trying to live inside its own name.

A derived query method name is a filename. It works while the name is genuinely a name. It stops working when the name becomes the content.

My own repository has not reached that point. The longest name in it is this:

List<Decision> findByUserUserIdAndVerdict(UUID userId, Verdict verdict);
Enter fullscreen mode Exit fullscreen mode

That still reads as a sentence. UserUserId looks odd until you know the rule: Spring walks into the user relation and reads its userId field, so nested properties get spelled out by traversal.

Now imagine two more requirements arrive, a date range and a sort order. The name that expresses them is:

List<Decision> findByUserUserIdAndVerdictAndCreatedAtBetweenOrderByScoreDesc(
        UUID userId,
        Verdict verdict,
        LocalDateTime from,
        LocalDateTime to);
Enter fullscreen mode Exit fullscreen mode

Spring is perfectly happy with this. It parses cleanly and returns the right rows. The problem is entirely on the human side: four parameters whose order you work out by re-reading the name, and a name you cannot scan without decoding it.

The name is checked before your app runs

One real advantage of the derived style is easy to miss.

If you name a property the entity does not have, by typo or because someone renamed a field, the application fails at startup. You get a PropertyReferenceException, usually surfacing as "No property found for type". You do not discover it when the endpoint is first called in production. You discover it when the context refuses to start, which Baeldung walks through in detail.

That is a genuinely good property, and a reason to keep using derived methods for everything they handle well.

When the name stops paying for itself

There is no limit in the framework. Spring will parse a name of any length. So this is judgement rather than a rule, and I would rather say so than invent a threshold and present it as official.

Three signals that a method name has stopped earning its place:

You cannot read it aloud in one breath. The name exists so a reader understands the method without opening anything else. If saying it out loud is work, it has stopped doing its job.

You had to count the Ands to get the parameters in the right order. Positional parameters governed by a name is a fine arrangement for two of them. At four it is a puzzle, and puzzles get solved wrongly.

The name encodes something the reader still has to decode. CreatedAtBetween is decodable. A name carrying three conditions, a range and a sort order is a specification in disguise.

Decision diagram titled

The same query, written the other way

@Query moves the query into an annotation and leaves the method name free to say what the method is for:

@Query("""
    select d from Decision d
    where d.user.userId = :userId
      and d.verdict = :verdict
      and d.createdAt between :from and :to
    order by d.score desc
    """)
List<Decision> findDecisionsForUserInRange(
        @Param("userId") UUID userId,
        @Param("verdict") Verdict verdict,
        @Param("from") LocalDateTime from,
        @Param("to") LocalDateTime to);
Enter fullscreen mode Exit fullscreen mode

Longer, and easier to read, which is the trade this whole article is about. The conditions sit on separate lines. The parameters are named rather than positional, so @Param("verdict") tells you what the second argument is without counting anything. And the method name went back to being a name.

You also get what a method name cannot express: joins you control, projections into a DTO, aggregate functions, and anything where the query needs to be shaped rather than merely described.

What you give up is that findByEmail cannot lie to you about what it does, and findDecisionsForUserInRange can, because the name and the query are now two things a future change can pull apart.

The safety myth, and the real gap

One claim about this comparison gets repeated often and is worth correcting.

The claim goes: derived queries are checked at startup so they fail fast, while @Query is just a string and blows up at runtime. It sounds reasonable. It is not true.

JPQL inside @Query is validated at startup as well. During context startup Spring calls EntityManager.createQuery() for each one, and the JPA specification requires that to throw IllegalArgumentException for an invalid query string. A typo in your JPQL breaks the boot. It does not wait politely for the first request. A common way to meet this is writing database column names instead of entity field names, which fails immediately, because JPQL is expressed in terms of your entities rather than your tables.

So the safety difference is not derived versus @Query. Both are JPQL underneath, and both get checked.

The real gap is native queries:

@Query(value = "select * from decisions where user_id = :userId",
       nativeQuery = true)
List<Decision> findByUserNative(@Param("userId") UUID userId);
Enter fullscreen mode Exit fullscreen mode

With nativeQuery = true the string is not parsed as JPQL, so it does not get that startup validation. Your database tells you it is wrong at the moment you run it. That is the actual trade, and it is worth knowing before reaching for native SQL to avoid learning a JPQL construct.

Where I actually am

Honest position: every repository in my project uses derived methods only. I have not written a single @Query yet, because nothing I have needed has outgrown a method name.

That is worth saying plainly, because articles comparing two tools usually imply the author switches between them daily. I am writing this from the other side, the side where one tool still covers everything, and I wanted to know where its edge is before I hit it rather than after.

The rule I am taking from working that out: start with the derived method, and move to @Query at the point where the name stops being a sentence. It is about readability rather than capability, because capability rarely forces the decision. Derived methods express far more than most people use, so waiting until the name genuinely cannot express the query means writing several names nobody wants to read first.

Honest notes

There is no performance argument in this article, in either direction, and that is a choice rather than an oversight. For equivalent criteria the two produce equivalent queries. Nothing here was measured, so there is no number in this piece, and I would be careful with any article that hands you one without saying how it was produced.

Native queries buy real power and cost you the startup check and portability across databases. Worth paying sometimes. Not worth paying by accident.

This is also only about how the query is written, not what it fetches. A tidy derived method on a wide entity can still pull far more than you intended, which is a different problem and a different article.

Recap

Spring Data writes your query from your method name, and that name is a filename: excellent while it is a name, a liability once it becomes the content. Derived methods are checked at startup, which is a real benefit, but so is JPQL in @Query, so failing fast is not what separates them. Only native queries skip the check. Reach for @Query when the method name stops reading like a sentence, and reach for native SQL deliberately or not at all.

Where do you draw the line: at three conditions, at the first join, or only when a derived method genuinely cannot express the query? I suspect people are less consistent about this than they think.

P.S. If this was useful, subscribe. I write one piece like this every week.

Top comments (0)