Grails Domain Save Appears Successful but No Data Persists – Diagnostic Guide
When a Grails controller or service returns success after a domain save yet the database shows no new or updated row, follow these checks to uncover silent validation, binding, or transaction issues.
12 Jul 2026, 06:06 UTC

Recognizable Condition
A POST request to a Grails controller or a direct service call returns a success status, the save() method returns a domain instance (not null), and no exception is thrown. Yet when you query the database the row is either unchanged or missing entirely. The user sees no validation feedback in the UI or logs.
Cause and Diagnostic Overview
| Symptom | Likely Cause | What to Look For |
|---|---|---|
| Save returns instance, no error, DB unchanged | Validation errors silently swallowed by save() (no failOnError) | Domain instance has non‑empty errors collection after validate() |
| Save returns instance, DB unchanged, null values in columns | Data binding did not populate a property (bindable false or missing from whitelist) | Property value is null despite being present in request params |
| Save returns instance, DB unchanged, version column unchanged | Optimistic locking version mismatch; Hibernate discards the update | Domain instance’s version does not match the row’s version in DB |
| Save returns instance, DB unchanged, no SQL issued | Service method lacks transactional boundary; flush never occurs | Hibernate session is not flushed or is in FlushMode.MANUAL without explicit flush |
Ordered Checks
-
Check validation silently.
- Load the domain instance in the Grails console (
grails console) or a unit test. - Set its properties exactly as they would be bound from the request (use a map or command object).
- Call
domain.validate()and inspectdomain.errors. - If errors exist, re‑run
domain.save(failOnError: true)to surface the constraint violation as an exception.
Where to run: Grails console or any test class with
@IntegrationTest. Permissions: none beyond normal app access. Placeholder:def instance = new Book(title: params.title, author: params.author). Expected check:instance.errors.hasErrors()is true. Risk: none; this is read‑only. - Load the domain instance in the Grails console (
-
Verify data binding configuration.
- In the controller or service, log the raw request params (
params) and the bound instance beforesave(). - Confirm each property you expect to be set has
bindable = truein the domain class or is included in the whitelist (bindableorstatic constraints = { ... bindable true }). - If a property is missing, add
bindable trueor adjust the whitelist.
Where to run: In the controller action or service method (add temporary
log.debug"Params: ${params}"andlog.debug"Instance: ${instance}"). Permissions: ability to modify source and redeploy. Placeholder:class Book { static constraints = { title bindable: true author bindable: true } }. Expected check: After binding,instance.titleequals the value fromparams.title. Risk: exposing params in logs; remove after debugging. - In the controller or service, log the raw request params (
-
Confirm transactional boundaries and flush mode.
- Check the service method: it should be annotated with
@Transactional(Grails 5) orgrails.gorm.transactions.Transactional(Grails 6) or wrapped inwithTransaction { … }. - If you need immediate visibility (e.g., for a subsequent query in the same request), add
flush: trueto thesavecall or calldomain.save(flush: true). - Inspect Hibernate session flush mode: enable debug logging for
org.hibernate.SQLandorg.grails.ormto see whether anINSERT/UPDATEstatement is issued and whether acommitfollows.
Where to run: In
application.ymlorlogback.groovysetlogger org.hibernate.SQL = DEBUGandlogger org.grails.orm = DEBUG. Permissions: ability to change logging config and restart. Placeholder:@Transactional BookService.saveBook(Book book) { book.save(flush: true) }. Expected check: Log shows SQL statement and a commit; no "no transaction" warnings. Risk: increased log volume; revert after debugging. - Check the service method: it should be annotated with
Fixes Tied to Findings
- Validation errors: Change
save()tosave(failOnError: true)or explicitly returndomain.errorsto the caller so the UI can display messages. - Binding problems: Ensure each domain property intended for mass assignment has
bindable: true(or add it to the whitelist). Provide default values or make nullable columns accept null if appropriate. - Optimistic locking: If concurrent updates are expected, either retrieve the latest version before saving (
Book.get(id)) or handleorg.springframework.dao.OptimisticLockingFailureExceptionand retry. - Missing transaction/flush: Annotate the service method with
@Transactional(orgrails.gorm.transactions.Transactional) and usesave(flush: true)when you need the write to be visible before the request ends.
Escalation Criteria
- Repeated optimistic locking failures under load, indicating a design that cannot sustain the update frequency.
- Validation errors originating from database‑level constraints (unique indexes, check constraints) that are not captured by GORM validation.
- Suspected second‑level cache returning stale reads after a successful save; verify by disabling cache temporarily or using
evictto force a DB read.
Practical Verification
After applying a fix, repeat the original request and:
- Check the response for any validation messages (if applicable).
- Query the database directly (
SELECT * FROM book WHERE id = ?) to confirm the row reflects the expected values. - Ensure no new warnings appear in the
org.hibernate.SQLororg.grails.ormlogs.
If the row persists correctly and no errors are logged, the issue is resolved. Otherwise, return to the ordered checks and verify the next potential cause.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.