Dates, UUIDs and Decimals

The standard-library types Pydantic already parses - and the two traps that cost real money: floats and naive datetimes.

Overview

Why this module exists

JSON has strings, numbers, booleans, lists and objects. That is the whole list. It has no date, no UUID and no decimal. Yet your code needs all three, so something has to turn text into the right Python objects.

Pydantic does that work. It reads an ISO date string and gives you a date object. It reads a UUID string and gives you a UUID. It reads a number and, if you ask, gives you an exact Decimal. Each type has its own parsing rules and its own failure modes, and some of them catch people out.

This article explains why these types exist as a group and how Pydantic handles them. It covers dates and times, including the timezone trap, where a naive time and an aware time look the same but behave very differently. It then covers money, and why a float is the wrong type for it. You will see how to constrain a decimal to a set number of places. The last part covers UUIDs and file paths, which are small topics that save a lot of manual parsing.

Each of these types hides a small trap, and each trap is cheap to avoid once you know it.

Each one is covered with a short example so the behaviour is clear.

JSON has six types: string, number, boolean, null, array and object. Your domain has more. Dates, timestamps, durations, identifiers, money. None of them exist on the wire. All of them arrive as strings or numbers that have to be turned back into a thing handy.

That conversion is tedious, easy to get subtly wrong. Pydantic already does it. Knowing just what it accepts saves writing the parsing. Knowing where the traps are saves the bugs.

Worth knowing

date, datetime, time and timedelta all accept their ISO 8601 text, and datetimes also accept a Unix timestamp.
A naive datetime cannot be compared with an aware one — Python raises. Use AwareDatetime to reject naive input at the boundary instead of failing later.
Decimal(0.1) inherits the float's error; Decimal("0.1") does not. Send money as a string in JSON, and validate it as Decimal.
max_digits and decimal_places express what a currency amount is. There is no float equivalent.
UUID and Path accept their string forms and give you the real objects, with .version, .suffix and the rest.
model_dump() keeps these as Python objects; model_dump(mode="json") and model_dump_json() convert them to strings, because JSON has none of these types.

Dates, UUIDs and Decimals: The Types JSON Does Not Have

What Pydantic parses for you, and the two traps that cost real money.

Dates and times from strings

ISO 8601 text and Unix timestamps both work. This is what makes a model usable directly against JSON, which has no date type at all.

example_01.pyPydantic
Output

The timezone trap

A naive datetime and an aware one cannot be compared. If your system is timezone-aware, say so in the type and let bad input be rejected.

example_02.pyPydantic
Output

This is the first of the two costly ones.

A datetime string without an offset makes a naive datetime. One with no tzinfo. A string with an offset makes an aware one. Both are valid datetime objects and both pass a datetime annotation.

They can't be checked. naive < aware raises TypeError. Subtracting one from the other raises. So a model that accepts both will work just right until the day a client sends the other kind. Then fail one spot fully unrelated to the model.

The fix is to be clear in the type:

from pydantic import AwareDatetime

updated_at: AwareDatetime

Now a naive string is rejected at the boundary, with a clear message, at the point the bad data arrived. NaiveDatetime exists for the opposite need.

The general advice for anything with users in more than one place. Store and transmit UTC with an clear offset, use AwareDatetime in your models. Convert to local time only at the moment of display. A system that is always aware has no timezone bugs. A system that is inconsistently aware has nothing but.

Money is not a float

Binary floating point cannot represent most decimal fractions. Decimal can — but only if you keep the value as text on the way in.

example_03.pyPydantic
Output

This is the second costly trap. It isn't a Pydantic quirk. It's how binary floating point works.

0.1 + 0.2 isn't 0.3 in any language using IEEE 754. 0.1 can't be shown just in binary any more than one-third can be in decimal. The error is tiny and it builds up. Money code that builds up errors eventually makes a total that is a penny out and an afternoon no one enjoys.

Decimal shows decimal fractions just. But the conversion matters enormously:

Decimal(0.1)     # 0.1000000000000000055511151231257827
Decimal("0.1")   # 0.1

By the time a float exists, the data is already lost. Passing it to Decimal faithfully preserves the wrong number.

So money should travel as a string in JSON. Be checked as Decimal. That is why so many payment APIs quote amounts as "12.50" rather than 12.50. It's a rule worth adopting rather than fighting.

If a float is unavoidable on the way in, be aware that you've already lost exactness and any quantisation is damage control, not a fix.

Constraining a decimal properly

max_digits and decimal_places say what a currency amount actually is, and no amount of float will give you them.

example_04.pyPydantic
Output

UUIDs and paths

Both accept their string form, which is the only form JSON has. A UUID field also rejects a string that is not one.

example_05.pyPydantic
Output

UUID accepts a UUID or its string form. Rejects anything that isn't a valid UUID. Which is a truly handy check. An id from an untrusted source is a common injection vector when it's treated as an opaque string.

What you get back is a real UUID. .version and .hex work. Note that model_dump() gives you the UUID object and the JSON forms give the string. This Matters if you pass the dump to a thing expecting text.

Path accepts a string and gives you a pathlib.Path, with .suffix, .parent and the rest. A caution. Checking a thing as a Path doesn't make it safe. It doesn't check the path exists. It certainly doesn't prevent ../../etc/passwd. Path traversal is a security check you still have to write.

EmailStr deserves a mention because people look for it. It exists. It lives in a split package (email-checker) that must be installed. Without it, a plain str with a pattern is the pragmatic option. And worth remembering that no regular formula truly validates an email address. The only real check is sending a message to it.

What comes out the other side

None of these types exist in JSON, so serialising converts them. model_dump() keeps the objects; the JSON forms make text.

example_06.pyPydantic
Output

Dates and times

date accepts a date, an ISO string like "2026-08-26". A Unix timestamp. But only one that lands just on midnight. A timestamp with a time part raises date_from_datetime_inexact, on the thinking that silently discarding the time would lose data you might have needed.

datetime accepts a datetime, an ISO 8601 string with or without an offset. A Unix timestamp as an int or float. Both "2026-08-26T14:30:00" and the space-split variant work. So does a trailing Z.

time accepts "14:30" and "14:30:00.123".

timedelta accepts some seconds or an ISO 8601 duration such as "PT1H30M".

The pattern is the one from the coercion module. The type itself always works. The obvious textual form works. What you get back is a real object. .year, .weekday() and math all work without you having called strptime anywhere.

Constraining a decimal

Two constraints exist for decimals namely:

amount: Decimal = Field(gt=0, max_digits=8, decimal_places=2)

decimal_places=2 rejects "12.505". max_digits=8 bounds the total number of large digits. Together they express "a currency amount" far better than any float annotation can. They appear in the schema.

This is also a good example of a constraint carrying domain meaning. decimal_places=2 is a statement that this system deals in whole pence. This Is a real choice that would else live only in whoever's head made it.

What comes out

Because none of these types exist in JSON, output has to convert them. The mode decides whether it does.

model_dump() returns Python objects: a date stays a date, a Decimal stays a Decimal. Right for passing data within your program.

model_dump(mode="json") returns JSON-matching values: dates become ISO strings, decimals become strings, UUIDs become strings.

model_dump_json() returns the JSON text right.

The mistake worth avoiding is json.dumps(model.model_dump()). That passes date and Decimal objects to a serialiser that can't handle them. Raises TypeError: Object of type date isn't JSON serializable. A confusing error. The model clearly supports JSON. Use model_dump_json(), or model_dump(mode="json") if you need the dict first.

A checklist for these types

Use AwareDatetime rather than datetime for anything that crosses a timezone boundary. This means anything with users.

Use Decimal with a string input for money, always, and add decimal_places.

Use UUID rather than str for identifiers that are UUIDs. The check is free and catches malformed input at the door.

Use date rather than datetime when there's no time. A date that is secretly midnight in some timezone is a bug waiting for a daylight-saving transition.

And use model_dump_json() rather than assembling JSON yourself. The conversions happen where they're already correct.

Formatting on the way out

Pydantic serialises dates and datetimes as ISO 8601. This Is the right default and isn't always the format a consumer wants.

Changing it's a field serialiser. This Is the next tier's material. The shape is short:

@field_serializer("published_on")
def show_date(self, value: date) -> str:
    return value.strftime("%d %B %Y")

Worth a word of caution though. A model that serialises dates in a human format is making display output. Display formatting often belongs in the layer that knows the reader's locale rather than in the data model. For an API, ISO 8601 is almost always the correct answer and the consumer formats it.

The one common exception is a date-only field where the ISO string is already right and the concern is the *type*. Making sure a date doesn't accidentally serialise as a full datetime with a spurious midnight. Using date rather than datetime handles that at the source.

Timestamps, seconds and milliseconds

A hands-on trap when accepting Unix timestamps: JavaScript's Date.now() returns milliseconds. Most of the rest of the world uses seconds.

Pydantic assumes seconds. Hand it a millisecond timestamp and you get a date tens of thousands of years in the future, checked happily. It's a just right valid datetime.

There's no way for the library to know which you meant. The check is yours. A constraint on the datetime range is the cheapest guard:

updated_at: AwareDatetime = Field(le=datetime(2100, 1, 1, tzinfo=timezone.utc))

Anything from a millisecond timestamp fails that at once and obviously, at the boundary, rather than appearing in a report as the year 57,000.

Decimals and JSON output

Decimal serialises to a JSON string by default, not a number. That is deliberate and it's correct. A JSON number is a float to most parsers. Writing 12.50 as a number would hand the receiving end the same inexactness you used Decimal to avoid.

It does mean a consumer expecting a number gets a string. This At times surprises people integrating with an existing client. The answer is nearly always to fix the client rather than the output. But if you must emit a number, a field serialiser can do it. You should be aware you're choosing convenience over exactness.

The same thinking shows why money should arrive as a string. A payload that sends "12.50" and validates it as Decimal is exact from end to end. One that sends 12.50 as a JSON number has already lost the promise before your model saw it.

Comparing and storing

Two habits that prevent most date bugs downstream.

Store UTC, display local. Convert at the edges only. A system where all inner is UTC with an clear offset has no ambiguity anywhere. One that stores local times has an unanswerable question each time the clocks change.

Use the narrowest type. If a field is truly a date, annotate it date, not datetime. A datetime standing in for a date carries a time that means nothing. That meaningless midnight will eventually be shifted by a timezone conversion into the previous day. Choosing date makes that hopeless rather than unlikely.

A quick reference

date — ISO string, or a timestamp landing just on midnight.

datetime — ISO string with or without offset, or a Unix timestamp in seconds. Prefer AwareDatetime.

time — "14:30" or "14:30:00.123".

timedelta — seconds as a number, or an ISO duration like "PT1H30M".

Decimal — a string, always, for anything money. Constrain with decimal_places.

UUID — the UUID or its string form. Invalid strings are rejected.

Path — a string. Note that check says nothing about safety or existence.

None of these exist in JSON. All of them are strings on the wire. model_dump_json() is the thing that knows how to make them.

Why these traps are expensive

The two traps in this module. Naive datetimes and floats for money. Share a shape worth recognising. It's the shape of most costly bugs.

Neither fails at the point of the mistake. A naive datetime validates just right and sits in the model until a thing checks it to an aware one, possibly weeks later, in a other module, written by somebody else. A float price validates just right and is just right for each value anyone tests with. Wrong by fractions of a penny in aggregate.

That delay is what makes them costly. A bug that raises at once costs minutes. A bug that makes plausible wrong answers costs but long it takes for somebody to notice the totals don't merge, plus the work of finding out why.

Annotating AwareDatetime and Decimal moves both failures to the boundary. Here They're named, located and cheap. That is the same arg as the whole library, applied to the two types where getting it wrong costs the most.

The general lesson

each type in this module exists because JSON is poorer than your domain. The gap has to be closed one spot.

Closing it in the model means the conversion happens once, in a place that is declared, tested and visible in the schema. Closing it in the consuming code means it happens all over, differently. One of those places will forget the timezone or the decimal places.

Choosing the precise type isn't pedantry. It's deciding that the gap gets closed at the boundary rather than scattered through all downstream.

Questions people ask about Dates, UUIDs and Decimals

Why does Pydantic accept a string as a datetime? In lax mode it parses ISO 8601 strings and Unix timestamps into a datetime. In strict mode only real datetime objects pass from Python input.

What is the difference between a naive and an aware datetime? A naive one has no timezone and an aware one does. Use AwareDatetime or NaiveDatetime to require one or the other, and a bare datetime will take either.

Why should I use Decimal for money instead of float? Floats cannot represent most decimal fractions exactly, so 0.1 + 0.2 is not 0.3. Decimal keeps exact values, and max_digits and decimal_places constrain them.

How does Pydantic parse a UUID? It accepts a canonical string or a uuid.UUID object and returns a UUID. Use UUID4 if you want to require version 4, and a malformed string raises a uuid_parsing error.

How are dates and Decimals written to JSON? Datetimes come out as ISO 8601 strings and Decimals as strings, so no precision is lost. UUIDs also become strings.

Does Path check that the file exists? No, a plain Path only parses the string. Use FilePath or DirectoryPath to also require that the path exists on disk.

Where to go after Dates, UUIDs and Decimals

That ends the shapes. The last module in this tier returns to coercion with a sharper question. Now that you know what Pydantic will convert, when should you stop it?

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why does `Decimal("0.1")` differ from `Decimal(0.1)`?

  2. What happens when you compare a naive datetime with an aware one?

  3. Why does `json.dumps(m.model_dump())` fail on a model with a date field?

  4. Which constraint expresses 'a currency amount in whole pence'?

Cheat sheet

Dates, UUIDs and Decimals

Parse ISO dates, UUIDs and Decimals from raw strings, and avoid the two conversion traps that silently produce wrong numbers.

PYDANTIC · vizlearn.in/pydantic/dates_uuids_and_decimals.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.