# The UUID That Was Secretly a String (and the `.hex` That Gave It Away)

_Postgres was forgiving. SQLite was not. The truth came out in testing._

June 10, 2026 · 3 min · Amit Jethva

Type coercion is a generous host in production and a strict bouncer in tests. We learned this when a query that worked perfectly against PostgreSQL detonated in our SQLite-backed test harness with one of the more cryptic accusations a Python error can level:

```
AttributeError: 'str' object has no attribute 'hex'
```

`'str' object has no attribute 'hex'`. Cool. Cool cool cool. What's asking a string for its `.hex`, and why does it expect one?

## The setup

We had a UUID column, and code that filtered on it using a value that arrived from the outside world — a path parameter, a JSON body, a query string. And the outside world only knows one type: **strings**. So `tenant_id` shows up as `"3f2504e0-4f89-11d3-9a0c-0305e82c3301"`, a perfectly nice string.

```python
db.query(Model).filter(Model.tenant_id == tenant_id_str)  # str vs UUID column
```

## Why Postgres shrugged and SQLite screamed

PostgreSQL has a native `uuid` type and a forgiving driver. Hand it a string that *looks* like a UUID and it'll coerce it for you, no complaints, and the query just works. You never find out anything is wrong, because nothing visibly is.

SQLite has no native UUID type. SQLAlchemy fakes it with a custom type that, when binding a parameter, tries to call `.hex` on the value to serialize it — because it *expects a `uuid.UUID` object*, which has a `.hex` attribute. You handed it a `str`. Strings don't have `.hex`. The bouncer checks your ID, it's the wrong type, you're out.

Same code. Same query. One database papered over the type mismatch; the other refused to. The bug was always there — Postgres was just too polite to mention it.

## The fix: convert at the boundary

The right place to turn a string into a `UUID` is the moment it enters your system — at the endpoint, before it ever touches a query:

```python
from uuid import UUID

@router.get("/things/{tenant_id}")
def get_things(tenant_id: str):
    tid = UUID(tenant_id)          # coerce once, at the door
    return db.query(Model).filter(Model.tenant_id == tid).all()
```

Now the value is a real `UUID` everywhere downstream. The query layer gets the type it expects. `.hex` exists. Both databases are happy. And as a free bonus, `UUID("not-a-uuid")` raises a clean `ValueError` right at the boundary — so a malformed ID fails fast with a clear message instead of limping deep into your data layer.

## The moral

- **The database that's strictest in tests is doing you a favor.** SQLite caught a latent type bug that Postgres was silently absorbing. Disagreement between environments is a *signal*, not just an annoyance.
- **Coerce types at the boundary, not in the depths.** A string from an HTTP request should become its real type (`UUID`, `int`, `datetime`) the instant it arrives, so everything inside operates on the right type.
- A cryptic `AttributeError` deep in a serialization layer (`'str' has no attribute 'hex'`) is often a type that should have been converted three layers up.

The UUID was a string in a trench coat the whole time. SQLite just had the nerve to ask for ID.

---

_Amit Jethva is the CTO and co-founder of Nuvika Technologies Pvt Ltd, makers of [Fintropy](https://www.nuvikatech.com/Fintropy_Overview.html), a multi-cloud FinOps platform. Learn more at [nuvikatech.com](https://www.nuvikatech.com)._
