# How to Use Zod's preprocess for Pre-Parsing Data Transformation

> Learn how to use Zod preprocess for data transformation before validation. Transform, sanitize, and coerce data effectively with Zod pipes for robust error handling.

- Repository: [Colin McDonnell/zod](https://github.com/colinhacks/zod)
- Tags: how-to-guide
- Published: 2026-02-23

---

**Zod's `preprocess()` function creates a `ZodPipe` that applies a transformation to raw input before validation, enabling type coercion, sanitization, and async lookups while supporting early error handling via `ctx.addIssue()`.**

Zod's `preprocess` utility in the `colinhacks/zod` repository provides a powerful mechanism for transforming raw data before schema validation occurs. Unlike standard transforms that run after type checking, preprocessing operates on the initial `unknown` input, making it ideal for sanitizing user input or coercing primitive types. This guide examines the implementation details in [`packages/zod/src/v4/classic/schemas.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/schemas.ts) and demonstrates practical patterns for leveraging `preprocess` in your validation pipelines.

## What Is Zod preprocess?

`z.preprocess()` is a schema composition helper that constructs a **pipeline** (specifically a `ZodPipe`) connecting a transformation function to a target schema. According to the source code in [`packages/zod/src/v4/classic/schemas.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/schemas.ts) at lines 2404-2409, the function signature is:

```typescript
export function preprocess<A, U extends core.SomeType, B = unknown>(
  fn: (arg: B, ctx: core.$RefinementCtx) => A,
  schema: U
): ZodPipe<ZodTransform<A, B>, U> {
  return pipe(transform(fn as any), schema as any) as any;
}

```

This implementation creates a `ZodEffects` instance that first applies your transformation function to the raw input, then validates the result against the provided schema. The preprocessing step receives the unrefined input as type `unknown` and runs **before** any schema constraints are checked.

## How Zod preprocess Works Internally

When you invoke `z.preprocess(fn, schema)`, the resulting schema executes a four-stage pipeline:

1. **Receives raw input** of type `unknown` from the parser.
2. **Executes the transformation function** `fn` with the input and a refinement context (`ctx`).
   - The function may be **synchronous** or **asynchronous**.
   - Use `ctx.addIssue()` inside `fn` to report validation errors and halt processing without invoking the downstream schema.
3. **Returns the transformed value** to the next stage, or returns `z.NEVER` to abort the pipeline immediately.
4. **Validates against the downstream schema**, which receives the transformed value instead of the original input.

Because the transformation runs prior to validation, `preprocess` is distinct from `z.transform()`, which validates first and transforms second. This ordering makes `preprocess` essential for scenarios where the raw input shape differs fundamentally from the target type.

## Practical Zod preprocess Examples

### Basic Type Coercion

Use `preprocess` to coerce primitive values before they reach the schema. This pattern handles form inputs that arrive as strings but must function as numbers or booleans:

```typescript
import { z } from "zod/v4";

const castToString = z.preprocess((val) => String(val), z.string());

castToString.parse(123); // → "123"
castToString.parse(null); // → "null"

```

*Source reference:* [`packages/zod/src/v4/classic/schemas.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/schemas.ts)

### Async Data Fetching

The transformation function can return a Promise, enabling async operations like database lookups or API calls before validation:

```typescript
import { z } from "zod/v4";

const fetchUser = async (id: unknown) => {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) throw new Error("User not found");
  return await response.json(); // returns { name: string, age: number }
};

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
});

const userPreprocess = z.preprocess(fetchUser, userSchema);

// Resolves the fetch, then validates the response shape
await userPreprocess.parseAsync("42");

```

### Early Exit Validation Using ctx.addIssue

For conditional validation that must reject invalid input before expensive transformations, use the `ctx` parameter to add issues and return `z.NEVER`:

```typescript
import { z } from "zod/v4";

const nonEmptyNumber = z.preprocess((val, ctx) => {
  if (val === "" || val == null) {
    ctx.addIssue({ 
      code: "custom", 
      message: "value required" 
    });
    return z.NEVER; // Aborts pipeline; downstream schema never runs
  }
  return Number(val);
}, z.number());

nonEmptyNumber.safeParse(""); // → { success: false, error: ... }

```

*Test reference:* [`packages/zod/src/v4/classic/tests/preprocess.test.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/tests/preprocess.test.ts)

### Chaining with pipe

Combine `preprocess` with `.pipe()` to apply additional transformations after the initial schema validation:

```typescript
import { z } from "zod/v4";

const toUpperCase = z.preprocess(
  (val) => String(val).toUpperCase(), 
  z.string()
).pipe(z.string().min(3));

toUpperCase.parse("ab"); // Transforms to "AB", then fails min(3) validation

```

## preprocess vs coerce: When to Use Each

While Zod provides dedicated `z.coerce.*` helpers for primitive type casting, `preprocess` remains necessary for complex logic:

| Scenario | Recommended Approach |
|----------|---------------------|
| **String → Number** (simple form inputs) | `z.coerce.number()` |
| **Async external lookups** (API/database calls) | `z.preprocess()` |
| **Conditional early termination** (empty checks) | `z.preprocess()` with `ctx.addIssue()` |
| **Complex sanitization** (trim, regex, JSON parsing) | `z.preprocess()` |
| **Multi-step transformations** | `z.preprocess()` combined with `.pipe()` |

## Summary

- **`z.preprocess()`** constructs a `ZodPipe` that transforms raw input before validation, implemented in [`packages/zod/src/v4/classic/schemas.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/schemas.ts).
- The transformation function receives **`unknown`** input and a **`ctx`** object for reporting issues without executing the downstream schema.
- Return **`z.NEVER`** from the preprocess function to abort validation early and prevent the target schema from running.
- **`preprocess`** supports **async** functions, making it suitable for fetching or resolving data before type checking.
- For simple primitive coercion, prefer `z.coerce.*`; reserve `preprocess` for complex logic, async operations, or conditional validation.

## Frequently Asked Questions

### Can I use async functions with z.preprocess?

Yes. The transformation function passed to `z.preprocess()` can return a Promise. When using async preprocessing, call `.parseAsync()` or `.safeParseAsync()` on the schema rather than the synchronous methods. The parser awaits the preprocessing result before passing it to the downstream schema.

### How do I stop validation early inside a preprocess function?

Use the second argument to your preprocess function—the refinement context (`ctx`)—to add validation issues, then return `z.NEVER`. This pattern prevents the downstream schema from executing and immediately returns the custom error. Reference the test suite in [`packages/zod/src/v4/classic/tests/preprocess.test.ts`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/tests/preprocess.test.ts) for implementation examples.

### What is the difference between preprocess and transform in Zod?

**`z.preprocess()`** runs transformations on the raw `unknown` input **before** validation, while **`z.transform()`** validates the input against the base schema first, then applies the transformation to the parsed result. Use `preprocess` when you need to modify the input to match the schema's expected type; use `transform` when you want to convert an already-validated type into a different output format.

### Can I chain multiple preprocess steps together?

Yes. You can nest `z.preprocess()` calls or use the `.pipe()` method to chain multiple transformations. Each step in the pipe receives the output of the previous step. For complex pipelines, piping multiple preprocess schemas together allows you to compose reusable transformation logic while maintaining type safety throughout the chain.