Back to BlogTroubleshooting

Zod 4 .partial() Keeps .default() Values — and Can Wipe Your Data

Rupak Amin

Founder & Lead Engineer, RAITHub

9 min read

Short answer: in Zod 4, schema.partial() does not remove .default(). If a field has a default and you leave it out, parsing fills the default back in. So an update schema built with .partial() turns { id, order: 2 } into { id, order: 2, published: false, tags: [] }, and your database update overwrites columns you never meant to touch.

We hit this on the RAITHub website on 27 September 2026, during a pre-launch code review. Nothing was failing. The type-check was clean, lint was clean and all 381 tests passed. The bug was found by tracing one admin click from the browser to the database. This post shows exactly what happened, how to check whether your code has the same problem, and the small helper we used to fix it.

What exactly does Zod 4 do differently from Zod 3?

Zod 3 skips defaults inside .partial(). Zod 4 applies them. We ran the same schema against both major versions to confirm it, rather than trusting memory.

const caseStudy = z.object({
  title: z.string(),
  tags: z.array(z.string()).optional().default([]),
  published: z.boolean().optional().default(false),
  order: z.number().optional().default(0),
})

const caseStudyUpdate = caseStudy.partial().extend({ id: z.string() })

caseStudyUpdate.parse({ id: 'x', order: 2 })
Zod version (tested 27 Sep 2026)Result of parse({ id: 'x', order: 2 })Safe for PATCH?
3.25.76{ order: 2, id: 'x' }Yes
4.3.6 (the version in our repo){ tags: [], published: false, order: 2, id: 'x' }No
4.6.5 (latest at time of writing){ tags: [], published: false, order: 2, id: 'x' }No

Even the simplest case shows it: in Zod 4, z.object({ published: z.boolean().default(false) }).partial().parse({}) returns { published: false }. In Zod 3 it returns {}. If you migrated from Zod 3 to Zod 4 and reused .partial() for update schemas, your update endpoints may have changed behaviour without any error.

How did it break our admin panel?

Our case-study admin has a drag-to-reorder list and a publish toggle. Both send small partial updates. The API validated them with the partial schema and passed the result straight into the database update.

// admin UI: reorder sends only what changed
fetch('/api/admin/case-studies', {
  method: 'PUT',
  body: JSON.stringify({ id: study.id, order: newIndex }),
})

// API route
const parsed = caseStudyUpdateSchema.parse(await request.json())
const { id, ...data } = parsed
await prisma.caseStudy.update({ where: { id }, data })

Because of the defaults, every drag also sent published: false, isSample: false, techStack: [] and images: []. The effects:

  • Reordering unpublished case studies. Every study whose position changed was set to unpublished, and the page revalidation then took /work/<slug> offline straight away.
  • Reordering wiped content. The tech stack and image lists were replaced with empty arrays.
  • The publish toggle reset other fields. Publishing a study also reset its order to 0 and cleared its "sample" flag, which is the flag that keeps illustrative content off the public site.
  • Blog posts lost their tags. The same pattern in the blog admin meant that publishing a post from the list wiped its tags.

The admin UI only updated its local state with the one field it sent, so the screen looked correct until the next reload. That is what makes this class of bug dangerous: the data is wrong, but nothing on screen says so.

Why didn't tests, types or lint catch it?

Because every check tested something true. TypeScript sees that parse() returns a valid update type. The unit tests checked that valid input was accepted and invalid input rejected, which it was. No test asked the one question that mattered: does a partial update return only the fields that were sent?

This is the lesson we keep relearning in code rescue work: a green CI run proves what you tested, not what you didn't. The review that found this did not start from the code. It started from a user action (drag a card), followed the request through the API and the validation layer, and wrote down the exact object that reached the database.

How do you fix it?

Strip the defaults before calling .partial(), and keep them on the create schema. We wrote one small typed helper and used it for every update schema.

type StripDefaults<T extends z.ZodRawShape> = {
  [K in keyof T]: T[K] extends z.ZodDefault<infer Inner extends z.ZodType> ? Inner : T[K]
}

// zod 4 keeps .default() values inside .partial(), so an omitted field would be
// filled in and then written. Strip defaults first so omitted stays omitted.
function partialForUpdate<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
  const shape: Record<string, z.ZodType> = {}
  for (const [key, field] of Object.entries(schema.shape)) {
    shape[key] = field instanceof z.ZodDefault ? (field.unwrap() as z.ZodType) : (field as z.ZodType)
  }
  return (z.object(shape) as unknown as z.ZodObject<StripDefaults<T>>).partial()
}

export const caseStudyUpdateSchema = partialForUpdate(caseStudySchema).extend({
  id: z.string().min(1),
})

With the helper, parse({ id: 'x', order: 2 }) returns { order: 2, id: 'x' } on Zod 4.3.6 and 4.6.5. The create schema is untouched, so caseStudySchema.parse({ title: 't' }) still fills in tags: [], published: false and order: 0. The field validators still run on anything you do send, so order: -1 or a javascript: URL is still rejected.

The helper only unwraps a default at the top level of each field. That covers the usual .optional().default(x) pattern. If you nest defaults inside objects or arrays, test those shapes too.

Which other ways of fixing it did we consider?

OptionWhy we didn't choose it
Remove every .default() from the schemasThe create endpoints rely on them. You would move the defaults into route code and risk them drifting.
Write each update schema by handIt works, but you then maintain two copies of every field rule. The next new field gets a validator on one side only.
Filter the parsed object down to the keys in the raw request bodyEasy to forget on one route. It also hides the problem inside every handler instead of fixing it once.
Pin Zod 3It only delays the migration and leaves the trap in place for whoever upgrades next.

How do you keep it fixed?

Add a regression test that states the rule directly: a partial update returns exactly the fields that were sent. We added one per update schema: case studies, blog posts, services, logos, testimonials, industry pages and FAQs.

const cases = [
  ['caseStudyUpdateSchema', caseStudyUpdateSchema, { id: 'x', order: 2 }],
  ['blogPostUpdateSchema', blogPostUpdateSchema, { id: 'x', published: true }],
  // ...one row per update schema
] as const

for (const [name, schema, input] of cases) {
  it(`${name} returns exactly the fields sent`, () => {
    expect(schema.parse(input)).toEqual(input)
  })
}

That change, plus tests for related fixes, took the suite from 381 to 400 tests. The fix went out in the same pre-launch review that found the bug.

How do you check whether your own codebase has this bug?

Search for update schemas built with .partial(), then check whether the base schema has any .default(). If both are true and you are on Zod 4, you are probably affected.

  1. Search your code for .partial() and list the schemas it is called on.
  2. For each one, search the base schema for .default(.
  3. Parse a one-field object with the update schema and print the result. If fields you didn't send appear, you have the bug.
  4. Follow the parsed value to the database call. If it is spread into an update, those extra fields are being written.
  5. Check your data for the tell-tale signs: records that became unpublished, lists that became empty, counters reset to 0.

If you would like a second pair of eyes on a codebase that behaves strangely even though the tests pass, that is the kind of problem our Code Rescue and QA engagements exist for; send us the one bug that is costing you most as a "Fix a specific issue" request. You can see how we test our own work in how RAITHub tests software. For the same kind of rigor on a live platform, read the Sundor Skin case study.

Last reviewed: 27 September 2026. Tested with Zod 3.25.76, 4.3.6 and 4.6.5 on Node.js 24.

Frequently asked questions

Does Zod 4 .partial() remove default values?

No. In Zod 4, a field with .default() still gets its default when you omit it, even after .partial(). In Zod 3 the omitted field stayed undefined. We confirmed this on 27 September 2026 with Zod 3.25.76, 4.3.6 and 4.6.5.

Why is this dangerous for update endpoints?

Update endpoints usually pass the parsed object into a database update. Every default that was filled in becomes a real write, so a request that meant to change one field can reset booleans, empty arrays and set counters back to 0.

How do I make a partial Zod 4 schema that leaves omitted fields out?

Unwrap each ZodDefault field before calling .partial(), for example with a small helper that rebuilds the object shape from field.unwrap(). Keep the defaults on your create schema so new records still get them.

Will removing the defaults stop validation on fields I do send?

No. Only the default is removed. The inner validators such as min, max, URL checks and custom refinements still run on every field that is present in the request.

Can TypeScript catch this bug?

Not directly. The parsed object is a valid instance of the update type, so the types are correct. It takes a behavioural test, such as asserting that a partial update returns exactly its input, or a review that traces the request to the database.

How do I find out whether my data was already affected?

Look for records whose flags, arrays or order fields changed at the same time as an unrelated edit. Audit logs and updated-at timestamps help. Then compare them against a backup from before the Zod 4 upgrade.

Can RAITHub review our codebase for issues like this?

Yes. RAITHub's Code Rescue engagement starts with a 2-week diagnostic of the codebase and infrastructure, and the QA and Reliability retainer adds CI-gated tests. Book a free 15-minute technical audit through the contact page to discuss it.

ZodTypeScriptValidationPrismaNext.jsBug fix

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.