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.

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:

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, a multi-cloud FinOps platform. Learn more at nuvikatech.com.