Skip to main content

Python None Cannot Represent an Unloaded Field

· 4 min read
Philip Z
Architect

Python makes absence easy to express. A database NULL, a missing JSON key, an empty relation, and an optional function result can all become None.

That convenience becomes dangerous for partially loaded entities. If a query did not select task.name, returning None tells business code something the runtime does not know.

TeaQL's generated Python E expressions keep Value, Null, and NotLoaded separate. Loaded nulls evaluate to None; unselected fields raise a structured TeaQLNotLoadedError.

Two Objects That Look the Same

Consider these two tasks:

from models.task import Task

partial = Task(id=99)
loaded_null = Task(id=100, name=None)

Both have task.name is None, but their constructors received different evidence. The first record contained only id. The second explicitly contained name with a null value.

The generated model remembers the keys that were present, so E expressions can preserve the distinction:

from E import E, TeaQLNotLoadedError

assert E.task(loaded_null).name().eval() is None

try:
E.task(partial).name().eval()
raise AssertionError("expected NotLoaded")
except TeaQLNotLoadedError as error:
assert error.access_path == "name"
assert error.break_point == "name"

Why or_else Must Still Fail

It is tempting to make every access defensive:

label = E.task(task).name().or_else("Unnamed")

This fallback is correct when the query selected name and the value is NULL. It is incorrect when the query never selected name.

TeaQL therefore propagates NotLoaded through or_else. A fallback is a business rule for legitimate absence, not a substitute for loading the required data.

Typed Async Query Results

Raw row dictionaries are useful for reporting and aggregation, but E expressions operate on generated models with load-state metadata. Generated requests now provide typed async methods for this purpose:

tasks = await (
Q.tasks()
.with_name_is("Hello")
.comment("Read the task for display")
.purpose("Render the task detail page")
.execute_entities_for_list(ctx, service)
)

name = E.task(tasks[0]).name().eval()

execute_entities_for_list and execute_entity_for_one construct models from the returned row keys. A key with value None is loaded Null. A key absent from the row is NotLoaded.

The explicit .comment(...) and .purpose(...) calls remain part of the query contract; typed mapping does not bypass TeaQL's audit requirements.

What the Error Contains

TeaQLNotLoadedError exposes both Python attributes and a structured details payload:

except TeaQLNotLoadedError as error:
print(error.details)

A diagnostic includes:

error: TeaQLNotLoadedError
root: Task(id=99)
access_path: [name]
break_point: name
missing_preload: [name]
suggested_fix: select_name(...)
severity: error

For nested relations, the access path grows with the expression chain. The failure therefore points to the first missing preload instead of surfacing later as an AttributeError on None.

Why This Works Well with AI Coding

AI agents are much more reliable when a failure describes an exact local contract. A generic NoneType has no attribute ... leaves several possibilities: bad fixture data, a nullable business field, a missing relation, or a query that did not preload enough data.

The three-state error removes that ambiguity. During a unit or integration test, the agent can:

  1. read the root and broken access path;
  2. inspect the generated request methods;
  3. add the suggested field or relation selection;
  4. rerun the test;
  5. retain the test as a regression check for query completeness.

This is a practical form of AI-native framework design: generated APIs constrain the solution, and generated diagnostics explain how the constraint was violated.

Verification Evidence

The generated Python workspace was compiled and executed with its asyncio-style runtime. Tests covered loaded values, a loaded None, a partial entity, fallback propagation, foreign-key IDs, reverse lists, and typed entity query mapping.

The cross-language generator regression completed with 109 tests, zero failures, and zero errors. Database tests requiring connection variables remained conditional; separately, Java and .NET integration tests verified the same SQL NULL versus NotLoaded contract against real database readers.

None remains the right result for a legitimately absent value. TeaQL simply refuses to use it as a disguise for data the program never loaded.