PayloadCMS - How to "dry-run" a CREATE operation?
Author
joprocorp
Date Published

This package is available in NPM under @lizardglobal/payload-dry-run .
If you’ve built with PayloadCMS, you know how slick its Local API, hooks ecosystem, and field-level validations are. You define a schema, write your logic, and let Payload handle access control and mutations. It’s seamless when you want to mutate data directly... but what happens when you want to know what a mutation would do before actually committing it?
Payload provides robust support for database transactions in its Local API. If you want to test what a mutation will do without persisting changes, the recipe is straightforward: pass the req.transactionID into your payload.create() or payload.update() call, grab the processed document, and then throw an error or abort the transaction.
While the database engine handles this cleanly via rollbacks, constantly re-writing this boilerplate across different API endpoints gets tedious. That’s why I created @lizardglobal/payload-dry-run. A lightweight wrapper package designed to encapsulate and automate this exact transaction-rollback workflow into a simple helper function.
A problem
To illustrate where a dry-run helper saves time, consider a common feature: a bulk CSV import tool. Suppose an admin uploads a CSV containing 500 product rows. Before saving these records to the database, you want to show a Preview & Pre-flight Verification table in the UI:
- Show computed values: Display auto-generated slugs, calculated discounts, or transformed data resolved inside
beforeValidatehooks. - Highlight validation errors: Catch row-level schema failures or relation mismatches inline.
- Ensure zero database persistence: Guarantee no records are added or updated until the admin approves the import batch.
Instead of writing custom validation runners or manually boilerplate-wrapping each row in transaction lifecycle calls, you can run the operation through a standardized dry-run wrapper.
A solution
To be clear, @lizardglobal/payload-dry-run isn't reinventing the wheel. It relies directly on native database transactions and rollbacks. Instead of writing manual transaction wrappers everywhere, the package automates the standard transaction lifecycle:
The dry-run flow operates in two distinct phases: execution under an isolated database transaction, followed by an immediate rollback to prevent permanent side-effects.
- Endpoint Interception: The plugin intercepts Payload's local API operations (ie.
createoperations) when adryRun: trueparameter or request flag is present. - Context Flagging: It injects a custom context flag (
req.context.dryRun = true) into Payload's request object. - Hook Bypassing / Simulation: Collection hooks and the plugin itself checks for this context flag to bypass side-effects, but executes normal schema validations and field transformations.
- Rollback: Once all hooks have ran, the final hook that's executed is the plugin's which simply rolls back the transaction, effectively cancelling all write operations made on the database.
- Response Generation: It returns the transformed, fully-validated document payload back to the caller so you can inspect what the final data structure would look like without modifying the underlying database.
While rolling back a SQL or MongoDB transaction restores database state, it does not undo side effects running in JavaScript memory. If a collection's afterChange hook sends a welcome email or emits a webhook, rolling back the database transaction won't stop that external network call.
The wrapper addresses this by injecting a dry-run flag into the request context (req.context.isDryRun). This allows you to easily gate side-effect hooks.
It's important that your project uses proper lifecycle events through Payload's hooks, and also passes the req object through every Payload local API calls (eg. payload.update({ req })). Otherwise, the transaction rollback triggered by the plugin will not fully cancel write operations and your database may end up with a partially written state.
Installation
Getting started with @lizardglobal/payload-dry-run takes less than two minutes.
- Prerequisites: Payload v2+ or v3+ with transaction-capable DB.
- Ensure your Payload CMS instance uses a database adapter that supports native transactions:
- PostgreSQL: Supported out of the box (
@payloadcms/db-postgres). - MongoDB: Supported when running on a replica set or Atlas cluster (
@payloadcms/db-mongodb).
- PostgreSQL: Supported out of the box (
- Install the package:
1# npm2npm install @lizardglobal/payload-dry-run3# pnpm4pnpm add @lizardglobal/payload-dry-run5# yarn6yarn add @lizardglobal/payload-dry-run
- Add the plugin to the payload configuration
1import { dryRunCreatePlugin } from '@lizardglobal/payload-plugin-dry-run-create'2import { buildConfig } from 'payload'34export default buildConfig({5 // ...6 plugins: [7 dryRunCreatePlugin({8 collections: ['users', 'orders'],9 }),10 ],11})
- Configure safeguards (optional)
If any of your collections execute external network calls or use the Payload operation calls inside afterChange or beforeChange hooks (like sending emails or triggering external Webhooks), guard them using the dry-run context flag in req.context?.isDryRun.
- Run it
Use payload.create(), payload.update() or via the API endpoints and pass the _dryRun field with true to trigger the dry-run.
Edge cases
- Auto-Incrementing Sequences: In relational databases like Postgres, rolling back a transaction does not reset sequence generators (e.g.,
SERIALorBIGSERIALauto-increment primary keys will still increment). - External API Calls in
beforeChange: External HTTP requests made insidebeforeValidateorbeforeChangehooks execute before the rollback occurs, so they must be guarded withreq.context.isDryRun. - Use transactions in Payload operations: If you use payload operations in your project (which is very likely), then you must pass the transaction through it every time. Otherwise, you might get wrong data since it sits outside of the request's transaction block.
- Database Driver Support: Since the package relies on native transactions, your configured Payload database adapter (e.g.,
@payloadcms/db-postgresor@payloadcms/db-mongodbwith replica sets) must support transaction blocks.
While using transactions to simulate changes is a standard pattern, packaging that logic into a clean, reusable helper eliminates boilerplate across pre-flight forms, preview steps, and import pipelines.
I’d love to hear your feedback!

PayloadCMS - How to render Lexical Editor content in React Native?
React Native implementation of the PayloadCMS Rich Text Renderer for serialized Lexical Editor content

PayloadCMS - How to set up ON DELETE CASCADE in collections?
Payload CMS plugin that automatically handles cross-collection reference cleanup on cascade deletes.