django-temporal-inputs
Native HTML5 date, datetime and time input widgets for Django, with optional lower and upper bounds.
Requires Python 3.12 or newer and Django 5.2 LTS or newer. Every supported combination — Python 3.12/3.13/3.14 against Django 5.2/6.0/6.1 — is exercised by the test suite on every push.
The gap this fills
Django ships widgets for text, number, email, url, color, search,
tel, password, file, checkbox, select and radio. It ships none for
date, datetime-local or time: those three fields render as text inputs, so
the browser offers no picker, no keyboard, and no validation of its own.
| Django's widget | this package | |
|---|---|---|
DateField |
type="text", value localised |
type="date", value always ISO 8601 |
DateTimeField |
type="text", date and time separated by a space |
type="datetime-local", separated by the T the browser requires |
TimeField |
type="text" |
type="time" |
Why not just attrs={"type": "date"}?
That is the answer you will find everywhere, and it gets you most of the way there:
forms.DateInput(attrs={"type": "date"}) does render type="date", and the browser
does show its own picker. The trap is the value.
These input types accept only ISO 8601, while Django localises the value it
renders. Under an Italian locale the date widget emits value="21/08/2026", the
browser cannot parse it, and the field silently appears empty — with the date
sitting in the database the whole time. Nothing raises; the form just looks blank.
The datetime case is worse, and it is worse everywhere. <input type="datetime-local"> requires a T between the date and the time, and Django
writes a space under every locale it ships, English included:
forms.DateTimeInput().render("starts_at", datetime(2026, 8, 21, 12, 30))
# <input type="text" name="starts_at" value="2026-08-21 12:30:00">
# ^ not a T, so: empty field
Installation
pip install django-temporal-inputs
No INSTALLED_APPS entry is needed — these are widgets, not an application.
Usage
from datetime import date, datetime, time
from django import forms
from temporal_inputs import DateInput, DateTimeInput, TimeInput, end_of_year
class BookingForm(forms.Form):
starts_on = forms.DateField(
widget=DateInput(min_date=date(2020, 1, 1), max_date=end_of_year(5))
)
starts_at = forms.DateTimeField(
widget=DateTimeInput(min_datetime=datetime(2020, 1, 1, 9, 0))
)
opens_at = forms.TimeField(
widget=TimeInput(min_time=time(9, 0), max_time=time(17, 30))
)
Renders as:
<input type="date" name="starts_on" required id="id_starts_on" min="2020-01-01" max="2031-12-31">
<input type="datetime-local" name="starts_at" required id="id_starts_at" min="2020-01-01T09:00">
<input type="time" name="opens_at" required id="id_opens_at" min="09:00" max="17:30">
Bounds are optional — omit them and you get a plain native input.
Bounds
Each widget names the pair it accepts after the thing being bounded, rather than
sharing a generic min_value:
| Widget | Bounds | Accepts |
|---|---|---|
DateInput |
min_date, max_date |
a date, or a callable returning one |
DateTimeInput |
min_datetime, max_datetime |
a datetime, a date, or a callable returning either |
TimeInput |
min_time, max_time |
a time, or a callable returning one |
They may equally be declared on a subclass:
class BookingDateInput(DateInput):
min_date = date(2020, 1, 1)
max_date = end_of_year(5)
An explicit min or max in attrs always wins:
DateInput(attrs={"min": "1999-01-01"}, min_date=date(2020, 1, 1)) # min stays 1999-01-01
Relative bounds
A bound may be any zero-argument callable returning the right type. Two helpers cover the common cases:
| Helper | Returns |
|---|---|
start_of_year(offset=0) |
1 January, offset years from the current year |
end_of_year(offset=0) |
31 December, offset years from the current year |
Callables are resolved at render time, not on instantiation. That matters in a long-running process: a worker booted in December that computed its upper bound once would keep serving last year's limit after midnight on 1 January.
DateInput(min_date=start_of_year(-1), max_date=end_of_year(5))
Both helpers return a date, and DateTimeInput takes one as covering the whole
of the day it names — midnight for a lower bound, the last minute for an upper
one. A date quietly meaning midnight would exclude that entire day from an
upper bound, which is an off-by-a-day in the direction that hurts:
DateTimeInput(max_datetime=end_of_year()) # max="2026-12-31T23:59", not T00:00
Timezones
Nothing to configure: Django already handles this and does it correctly.
DateTimeField.prepare_value converts an aware value to the current timezone
before the widget sees it, and to_python converts a submitted naive one back —
raising a validation error for a wall clock that DST makes ambiguous or
non-existent. The widget therefore renders local wall-clock time.
An aware datetime given as a bound is converted the same way, so it lands on
the same clock as the value it bounds:
# with TIME_ZONE = "Europe/Rome"
DateTimeInput(min_datetime=datetime(2026, 8, 21, 10, 0, tzinfo=UTC)) # min="2026-08-21T12:00"
Seconds
DateTimeInput and TimeInput render to the minute by default, because that is
the precision the browser's default step of 60 permits. A value carrying
seconds under that step is a step mismatch: the browser marks the field invalid
and constraint validation refuses to submit the form. Pass seconds=True to
render and edit them, which emits the matching step="1":
TimeInput(seconds=True).render("opens_at", time(12, 30, 45))
# <input type="time" name="opens_at" value="12:30:45" step="1">
Leaving it off means a stored value's seconds are dropped when the form is
resubmitted. An explicit step in attrs wins over both.
The format is fixed
There is no format argument on any of the three. Each input type has exactly one
value format, so offering a choice would only offer a way to break the widget:
DateInput(format="%d/%m/%Y") # TypeError
If you want a text input in a localised format, that is Django's own
forms.DateInput. These are the HTML5 inputs.
Notes
- Bounds are a client-side convenience. Browsers block out-of-range values in the
picker, but a crafted POST will sail past them — validate on the field too, with
MinValueValidator/MaxValueValidatoror aclean_*method. - Parsing the submitted value back is sturdier than it sounds, and it is sturdy in
three different ways:
DateTimeFieldcannot be broken at all. Itsto_python()runsparse_datetime(), which accepts theTseparator, before it ever consultsDATETIME_INPUT_FORMATS.DateFieldandTimeFieldparse through their format lists, andget_format()appends its own ISO entries to whatever list a locale format module supplies. Django ships such a module for every locale bar a handful, so overriding theDATE_INPUT_FORMATSorTIME_INPUT_FORMATSsetting usually has no effect on ISO parsing at all.- The setting only becomes authoritative under a language Django ships no
formats.pyfor (Afrikaans, Armenian, Malay and a couple of dozen others). If that is yourLANGUAGE_CODEand you override either setting, keep the ISO entry.
License
MIT — see LICENSE.
Metadata
Release files for django-temporal-inputs 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_temporal_inputs-1.0.0.tar.gz | 34.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_temporal_inputs-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.9 kB
Release files / django_temporal_inputs-1.0.0.tar.gz
| Download URL | django_temporal_inputs-1.0.0.tar.gz |
|---|---|
| Size | 34.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5b85e1f0072acd8dd70ee907768143ccb83a66d9d13a9e2adae1e22aa70f49f6
|
|
BLAKE2b-256 checksum How to use checksums |
90fffd5d6a6ed5a725b94cf84b16ca1eed675f95b246badc6db0af036ee50c90
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / django_temporal_inputs-1.0.0-py3-none-any.whl
| Download URL | django_temporal_inputs-1.0.0-py3-none-any.whl |
|---|---|
| Size | 9.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8a79a6d552fcf1faebc068201cd6e9f238e07fca02ecdc5fa16a8cb1b3c5c251
|
|
BLAKE2b-256 checksum How to use checksums |
a6ddf5baf2939f3f4424507111a2ae49e885a7942111377c51df3b15ad799596
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|