# Date field (/docs/schema/fields/date)

---
title: Date field
description: "`f.date()` is the calendar day. No clock, no time zone, a `YYYY-MM-DD` string in and out, and a Postgres `date` column underneath."
kind: reference
package: questpie
---

| Surface               | Detail                                                    |
| --------------------- | --------------------------------------------------------- |
| Signature             | `f.date()`, no arguments                                  |
| Column                | `date`, built in Drizzle's `mode: "string"`               |
| Value                 | `string`, `"2026-08-01"`, on read and on write            |
| Schema                | `z.string().date()`                                       |
| Filter operators      | the date-string set, eleven entries, [below](#filtering)  |
| Admin form            | a popover calendar, a native date input under 768px       |
| Admin cell            | `toLocaleDateString()` in tabular figures, a dash on null |
| Type-specific methods | `.autoNow()` and `.autoNowUpdate()`                       |

```ts title="src/questpie/server/collections/announcements.ts"
import { collection } from "#questpie/factories";

export const announcements = collection("announcements").fields(({ f }) => ({
	validFrom: f.date().required(), // date NOT NULL, string on read
	validTo: f.date(), // nullable
}));
```

The factory takes no config. Three methods change the column. `.array()` swaps
it for `jsonb`, `.drizzle()` hands you the builder, and `.localized()` moves it
to the i18n table.

## The value is a string

Postgres holds a real `date`. Drizzle's string mode hands that text back
untouched. So the field is a `string` in the row, in the insert and in the
`where`. Ordering and ranges still run in the database, on a date column.

<Callout type="warn" title="`z.string().date()` rejects a `Date`">
	It rejects a full ISO timestamp too. `"2026-08-01"` passes. `new Date()` and
	`"2026-08-01T00:00:00.000Z"` do not. That is the deliberate split from
	[`f.datetime()`](/docs/schema/fields/datetime), which holds a `Date`.
</Callout>

## Methods

Two methods come with the type. `.required()`, `.default()`, `.label()`,
`.localized()` and the rest of the base chain work here as on any field.

| Method             | What it sets                                          |
| ------------------ | ----------------------------------------------------- |
| `.autoNow()`       | `hasDefault`, plus a default function returning today |
| `.autoNowUpdate()` | A `beforeChange` field hook returning today           |

Both compute `new Date().toISOString().slice(0, 10)`. Today means the UTC
calendar day. It is never a tenant day or a business day.

```ts
collection("posts").fields(({ f }) => ({
	publishedOn: f.date().autoNow(),
	reviewedOn: f.date().autoNowUpdate(),
}));
```

Neither method changes the field's type. So `f.date().required().autoNow()`
still demands the key in TypeScript. The runtime schema accepts the create
without it, because `hasDefault` makes the input optional.

<Callout type="warn" title="`.autoNow()` freezes one day into the column">
	The default function runs once, when the table is built. Its result goes to
	Drizzle's `.default()`, so Postgres keeps that day as the column DEFAULT. Rows
	created after your last push get the day you pushed.
</Callout>

<Callout type="warn" title="`.autoNowUpdate()` needs the key in the payload">
	Field hooks run per key. The runtime skips any field the write left out. Send
	`reviewedOn` and the hook overwrites what you sent. Omit it and nothing is
	stamped, so this is not an `updatedAt`.
</Callout>

`.autoNowUpdate()` keeps the field's other hooks and replaces `beforeChange`.
Call `.hooks({})` before it, never after. A later `.hooks({})` replaces the whole
bag.

`.inputFalse()` denies create and update on the field, so sending the key is a 403. `.autoNow()` survives that. Its value comes from the column DEFAULT, not
from your payload. `.autoNowUpdate()` does not survive it. The hook needs the
key, and the key is now a 403. Put a value you want on every write in a
collection [`beforeChange` hook](/docs/schema/hooks) instead. That one runs
either way.

## Filtering

A date field carries the date-string operator set. The operator object in its
`where` entry types to exactly these eleven keys.

| Operator              | Operand            | Matches                                          |
| --------------------- | ------------------ | ------------------------------------------------ |
| `eq`, `ne`            | `string`           | Equal, not equal                                 |
| `not`                 | `string` or `null` | Not equal, or `IS NOT NULL` when you pass `null` |
| `gt`, `gte`           | `string`           | After, on or after                               |
| `lt`, `lte`           | `string`           | Before, on or before                             |
| `in`, `notIn`         | `string[]`         | In the list, not in the list                     |
| `isNull`, `isNotNull` | `boolean`          | Null check, inverted when you pass `false`       |

A bare value is the shorthand and types alongside that object.
`{ validFrom: "2026-08-01" }` compiles to `eq`. `{ validTo: null }` compiles to
`IS NULL`.

```ts
const today = new Date().toISOString().slice(0, 10);
const { docs } = await app.collections.announcements.find({
	where: { validFrom: { lte: today }, validTo: { gte: today } },
});
```

Every operand is checked before it reaches SQL. The nine value operators run
through an exact calendar-date parse. Anything but a `YYYY-MM-DD` string is a
400 reading `Date filters require an exact YYYY-MM-DD calendar date`. `isNull`,
`isNotNull` and `not: null` skip that check. The SQL is the comparison
`f.datetime()` builds. Only the operand type differs.

## In the admin

The form control is `DateField`. It is a popover calendar on a wide viewport,
and a native `<input type="date">` below 768px. The switch is a width media
query, not touch detection. The list cell is `DateCell`.

The list filter builder gives the field a native date input. It offers the same
eight operators a number gets. It sends the `YYYY-MM-DD` string straight
through.

<Callout type="warn" title="The form control and the field disagree">
	The picker writes a `Date` into the form. The admin's client schema for `date`
	takes a `Date` or a full ISO timestamp. The server schema takes `YYYY-MM-DD`
	and rejects both. Filters and your own writes are unaffected.
</Callout>

`.admin(config)` sets the rest of the control. It appears on the field once
`@questpie/admin` is enabled. Its generated signature takes `unknown`, so
nothing type-checks the keys you pass.

| Key           | Type     | Default | Effect                                |
| ------------- | -------- | ------- | ------------------------------------- |
| `placeholder` | `string` | none    | The text shown while there is no date |

Every key you pass is copied onto the control. So `format` reaches the picker
even though the type never declares it. It is a date-fns pattern and defaults to
`PP`. Keys such as `showInList`, `listWidth`, `sortable` and `filterable` come
from the base admin config that every field type shares.

## Lists of dates

`.array()` replaces the `date` column with `jsonb`. It also swaps the eleven
operators for the array set. Every range comparison goes. What is left is
`containsAll`, `containsAny`, `eq`, `length`, `isEmpty`, `isNotEmpty`, `isNull`
and `isNotNull`. `eq` now takes the whole list. `.minItems(n)` and
`.maxItems(n)` bound it. Each item still validates as `YYYY-MM-DD`.

```ts
blackoutDates: f.date().array().maxItems(20),
```

The date control does not survive the wrap. The admin's array control knows
`text`, `textarea`, `number`, `email` and `select`. A list of dates falls back
to plain text inputs.

[Arrays](/docs/schema/fields/array) has the operator table that replaces this
one.

## Types

A date field contributes `string` to the row, the insert and the `where`.
`.required()` makes it non-null and mandatory on insert. `.default("2026-01-01")`
makes the input optional again, and the literal type-checks against `string`.
`.autoNow()` does the same at runtime, but not in the type.

```ts
type Announcement = typeof announcements.$infer.select;
//   ^? { id: string; validFrom: string; validTo: string | null; ... }
```

```ts
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Announcement = CollectionDoc<"announcements">;
type AnnouncementFilter = CollectionWhere<"announcements">;
```

## Related

[Temporal values](/docs/schema/fields/temporal-values) is the contract for what
crosses the wire, through realtime and into SSR.

[`f.datetime()`](/docs/schema/fields/datetime) is the instant, a `Date` in a
`timestamp(3) with time zone`.

[`f.time()`](/docs/schema/fields/time) is the clock without the day.

[Fields](/docs/schema/fields) is the table of every type and the modifiers they
all share.

[Arrays](/docs/schema/fields/array) covers `.array()` on any field.

[Validation](/docs/schema/validation) covers the derived schema and `.zod()`,
the way to add a rule the type does not carry.
