Collisions & Error Handling
This page covers the two things that surprise people at runtime: what happens when your Particle’s name clashes with a built-in, and how NULLs and errors behave when it runs.
Names can’t shadow a built-in
Section titled “Names can’t shadow a built-in”ParticleDB refuses to let a Particle shadow a built-in function — in either direction. This keeps built-ins unambiguous and prevents a Particle from silently masking (or being masked by) a core function.
A Particle whose name collides with a built-in is rejected when you load or deploy it, with:
- SQLSTATE
42723(duplicate_function) - Message:
cannot create particle "<name>": a built-in function with that name already exists
The check is kind-aware: a @scalar is checked against the scalar-builtin
set, and an @aggregate / @window against the aggregate-builtin set.
# mymodule.py — `upper` is a built-in scalar, so this name is taken.from particledb import scalar
@scalardef upper(s: str) -> str: return s$ particledb particles deploy ./mymodule.pyERROR: 42723: cannot create particle "upper": a built-in function with that name already existsThis is exactly why the statistics example uses pmedian rather than
median: MEDIAN is a built-in aggregate, so a median Particle would be
rejected. Pick a non-colliding name and it registers fine.
Re-deploying your own Particle is a replace
Section titled “Re-deploying your own Particle is a replace”A Particle never collides with itself. Deploy a Particle under a name you already own and the new body simply takes effect — no error, no duplicate:
# tag.py — version 1from particledb import scalar
@scalardef py_tag(s: str) -> str: return "v1:" + s$ particledb particles deploy ./tag.py # py_tag('x') -> 'v1:x'Change the body and deploy the same name again — it replaces, in place:
@scalardef py_tag(s: str) -> str: return "v2:" + s$ particledb particles deploy ./tag.py # py_tag('x') -> 'v2:x' (no error)The collision check only consults genuine built-ins — your own Particle registry is excluded, so re-deploying is always a safe replace.
Null handling
Section titled “Null handling”Particles are STRICT by default, matching PostgreSQL — but “STRICT” means something slightly different per kind:
| Kind | STRICT behaviour |
|---|---|
@scalar | If any argument is NULL, the result is NULL and the body is not called. |
@aggregate | NULL values are dropped from the group; an all-NULL or empty group returns NULL without calling the body. |
@window | NULLs are dropped from the frame values passed in; an empty frame yields no per-row values. |
SELECT domain_of(CAST(NULL AS text)); -- NULL (body never invoked)SELECT pmedian(x) FROM t_all_null; -- NULL (empty non-NULL group)Because STRICT means your body never sees NULL, a @scalar that would crash on
None is automatically safe over a nullable column. Opt out only when you truly
need to handle NULL yourself:
@scalar(called_on_null=True)def coalesce_blank(s: str) -> str: return s or ""When your body raises
Section titled “When your body raises”A Particle body is ordinary code — if it raises (a Python exception, or a Rust panic), that call fails the query with a clear error rather than returning a wrong value. Native execution is wrapped in a panic guard, so a bad row never corrupts state or takes down the server — it surfaces as a query error you can see and fix.
The practical guidance: write total functions. Validate or guard inside the
body and return a sensible value or NULL rather than raising on data you can
anticipate:
@scalardef safe_ratio(num: float, den: float) -> float: if den == 0.0: return None # -> SQL NULL, not a divide-by-zero error return num / denReturning None from a Python body maps to SQL NULL, which is usually the
right “no answer” for a row you can’t compute — cleaner than raising and aborting
the whole statement.
Next steps
Section titled “Next steps”- Best Practices — naming, determinism, and safety.
- Loading — how these get into a running server.
- Python — the decorators and tested examples.