Skip to content

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.

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
@scalar
def upper(s: str) -> str:
return s
$ particledb particles deploy ./mymodule.py
ERROR: 42723: cannot create particle "upper": a built-in function
with that name already exists

This 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 1
from particledb import scalar
@scalar
def 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:

@scalar
def 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.

Particles are STRICT by default, matching PostgreSQL — but “STRICT” means something slightly different per kind:

KindSTRICT behaviour
@scalarIf any argument is NULL, the result is NULL and the body is not called.
@aggregateNULL values are dropped from the group; an all-NULL or empty group returns NULL without calling the body.
@windowNULLs 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 ""

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:

@scalar
def safe_ratio(num: float, den: float) -> float:
if den == 0.0:
return None # -> SQL NULL, not a divide-by-zero error
return num / den

Returning 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.

  • Best Practices — naming, determinism, and safety.
  • Loading — how these get into a running server.
  • Python — the decorators and tested examples.