Use of JSON/JSONB in the Database
Status: Implemented
Assignee: Alfons Mueller
Problem
Developers currently lack a clear decision on whether to continue using JSON/JSONB fields or normalized database structures when creating schemata.
This uncertainty affects migrations, schema consistency, and data modeling decisions across the backend.
Current State
JSONB Usage in Database
- Currently used only in
drawings.propertiescolumn (JSONB type) - backend/src/model/entity/drawings.rs:21 - Stores polymorphic shape-specific data with 6 drawing types
- No other tables use JSONB
- Current JSON usage can be migrated to normalized tables without losing data, but this will require backend rewrites and database work. (creating tables & inserting data)
Why JSONB Was Used - Polymorphic Data Pattern
- Each drawing has
shape_typeenum (Rectangle, Ellipse, FreeLine, BezierPolygon, LabelText, Image) - Different shapes have different properties (Rectangle has width/height, Ellipse has radius_x/radius_y, etc.)
- JSONB
propertiescolumn contains shape-specific attributes - Benefits:
- Avoids sparse table with many nullable columns (one column per shape-specific property)
- Flexible for adding new shape types
- Works well with Rust's enum-based type system (DrawingVariant)
- Type safety: Maintained at application layer via serde deserialization - backend/src/model/dto/drawings.rs:89-97
- Error handling: Mismatch between shape_type and JSON structure returns 500 error - backend/src/model/dto/drawings_impl.rs:17-22
Common vs. Variant-Specific Attributes
- Common (stored as columns): id, layer_id, add_date, remove_date, rotation, scale_x, scale_y, x, y, notes
- Variant-specific (stored in JSONB properties):
- Rectangle: width, height, color, fill_pattern, stroke_width
- Ellipse: radius_x, radius_y, color, fill_pattern, stroke_width
- FreeLine/BezierPolygon: points array, color, fill_pattern, stroke_width
- LabelText: text, width, height, color
- Image: path
The question is whether we should continue this practice, extend it to similar polymorphic or optional data (e.g., plant attributes), or phase it out in favor of fully normalized tables.
JSONB Cost vs. Benefit for Polymorphic Data
-
Cost
- No native FK, UNIQUE, or NOT NULL constraints inside JSON. Checks have to be re-implemented with triggers or generated columns. Currently these checks are not in use but might become necessary in the future if we introduce new JSON applications.
- Type safety is only done via backend validation.
- Renames/splits require backfills or application-layer migration across versions.
- More involved value transformations (RGB to RGBA) require custom PL/pgSQL functions or application-layer migration (no simple SQL).
- More involved array transformations (Rotating/scaling points in FreeLine/BezierPolygon) require custom functions (no simple UPDATE).
-
Benefit
- Avoids many nullable columns or many subtype tables when variants overlap only a little.
- New fields can be added easier. (No new tables)
- The API can write the whole object in one step, without branching into type-specific tables.
Research findings on JSONB migration capabilities
-
Extensibility (adding/renaming attributes in JSONB):
- ✅ Adding new properties: Supported via PostgreSQL subscripting:
UPDATE drawings SET properties['fill_pattern_color'] = '"#FFFFFF"' - ⚠️ Renaming properties: No built-in support - requires extract old value → delete old key → create new key (multi-step process)
- ✅ Adding new properties: Supported via PostgreSQL subscripting:
-
Migration Flexibility (updating existing JSONB data):
- ✅ Simple updates: Can update JSONB properties via subscripting
- ⚠️ Value transformations: RGB to RGBA conversion would require custom PL/pgSQL functions or application-layer migration (no simple SQL)
- ⚠️ Array transformations: Rotating/scaling points in FreeLine/BezierPolygon would need custom functions - can't be done with simple UPDATE
- Current state: No JSONB-to-JSONB transformations in migrations yet - only column-to-JSONB and JSONB-to-column
-
Data Mismatch Likelihood:
- ⚠️ Current risk: Mismatch between
shape_typeand JSONB structure returns 500 error - Mitigation: Type safety enforced at application layer via
DrawingVariantand serde deserialization - Risk sources: Manual database edits or buggy migrations could create invalid combinations
- No DB-level validation: PostgreSQL doesn't validate JSONB structure matches the shape_type enum
- ⚠️ Current risk: Mismatch between
Migration Likelihood Estimation by Type
In order to decide on an solution it is important to estimate the likelihood of migrations because some types require more effort when JSON is involved.
⚠️ This section is only an estimation and is subject to discussion.
- Adding properties: high probability, when new functionality is required
- Renaming properties: low probability, properties should be named correctly when introduced (reviewed)
- Value transformations: low-medium probability, unlikely for current use in
drawings(e.g. could simply introduce separateopacityproperty) but could possibly be required if new JSON applications are introduced - Array transformations: low-medium probability, unlikely for current use in
drawings(coordinate system is established, freeline and bezier fully implemented) but could possibly be required if new JSON applications are introduced
Example Migrations
1. Simple renaming
Renaming the color attribute to fill_color in existing drawings:
UPDATE drawings
SET properties = (properties - 'color')
|| jsonb_build_object('fill_color', properties->'color')
WHERE properties ? 'color';
compared to a simple ALTER TABLE:
ALTER TABLE drawings
RENAME COLUMN color TO fill_color;
2. Extracting JSONB data into tables
In this example we show how a lossless extraction of data from the properties column of drawings might look like.
First we create the tables that will hold the different shapes.
For this example we only create the tables necessary to hold bezier polygons.
All other shape types can be handled in the same way, with bezier polygons already being the most complicated shape type currently implemented.
One table to hold the properties of the bezier polygons (linked to drawings by id):
CREATE TABLE IF NOT EXISTS public.bezier_polygon_props
(
drawing_id uuid NOT NULL,
color text COLLATE pg_catalog."default",
fill_pattern text COLLATE pg_catalog."default",
stroke_width numeric,
CONSTRAINT "bezier_polygon_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "bezier_polygon_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
)
And a second table to hold the point data of the bezier polygons (linked to bezier_polygon_props by id):
CREATE TABLE IF NOT EXISTS public.bezier_polygon_point
(
bezier_polygon_id uuid NOT NULL,
idx integer NOT NULL,
x numeric,
y numeric,
CONSTRAINT "bezier_polygon_point_PK" PRIMARY KEY (bezier_polygon_id, idx),
CONSTRAINT "bezier_polygon_point_FK" FOREIGN KEY (bezier_polygon_id)
REFERENCES public.bezier_polygon_props (drawing_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
)
After creating the necessary tables we can start extracting the data from drawings to the corresponding subtables.
This is an example transaction covering the bezier polygon data stored in drawings.
Similar transactions would have to be done for all shape types.
BEGIN;
-- (a) Insert per-polygon properties
INSERT INTO bezier_polygon_props (drawing_id, color, fill_pattern, stroke_width)
SELECT id,
properties->>'color',
properties->>'fillPattern',
NULLIF(properties->>'strokeWidth','')::numeric
FROM drawings
WHERE shape_type = 'bezier polygon'
AND (properties ? 'color' OR properties ? 'fillPattern' OR properties ? 'strokeWidth')
ON CONFLICT (drawing_id) DO NOTHING;
-- (b) Insert points
INSERT INTO bezier_polygon_point (bezier_polygon_id, idx, x, y)
SELECT d.id,
p.ordinality - 1 AS idx, -- 0-based index
(p.elem->>0)::numeric AS x,
(p.elem->>1)::numeric AS y
FROM drawings d
JOIN LATERAL jsonb_array_elements(d.properties->'points') WITH ORDINALITY AS p(elem, ordinality)
ON TRUE
WHERE d.shape_type = 'bezier polygon';
COMMIT;
After running the transaction, from a drawings table that looks like this

we get a bezier_polygon_props table looking like this

and a bezier_polygon_point table looking like this.

Type Safety: JSONB vs. Typed Columns
1. Using JSONB
- JSONB shifts type safety into the application (Rust + serde) because PostgreSQL does not validate types, constraints, or foreign keys inside JSON.
- This means JSON is only “safe” as long as all writes go through validated backend code.
- This backend code has to be written by someone.
- To keep JSON migratable later, it must stay simple, non-critical, and not contain essential business constraints. (e.g. permissions, required relationships, validity rules,... )
- Good for: flexible, variant-heavy, optional UI/visual properties
2.Not Using JSON (Normalized Tables)
- PostgreSQL enforces types, constraints, and referential integrity automatically .
- Type safety is essentially free.
- Errors show up at write-time, not runtime.
Constraints
- Migrations involving JSONB must remain maintainable. Developers should be able to add, rename, and remove keys with minimal complexity.
- Queries on JSONB fields must perform adequately, with feasible indexing strategies when needed.
- The risk of data mismatches between application models (Rust types) and stored JSON structures must remain acceptably low.
- When writing migrations developers don't have access to full production data. (e.g., private maps) When rolling out migrations they might encounter data which they didn't encounter during development.
Assumptions
- The project expects further entities with type-dependent or sparsely populated attributes (e.g., entities where 20–80 % of fields differ per variant).
- Most JSONB data will be read and written through the Rust backend, not manually or by external systems.
- The current validation pattern using
serdewill continue to be enforced across any future JSONB usage.
Solutions
Alternative A: Hybrid Approach (Status Quo + Guidelines)
Continue using JSONB for polymorphic or sparse data like drawings.properties.
Establish project-wide guidelines for when JSONB is allowed, how to migrate it safely, and how to enforce validation.
Polymorphic data in this context means:
- Data whose shape depends on a discriminator (e.g., shape_type, plant_type, variant),
- Each variant sharing only a few common fields while having distinct attributes.
Sparse data means:
- Entities with numerous optional or rarely used attributes where adding individual columns would lead to excessive nulls.
JSONB may be used for such data if:
- The number of variant-specific keys per entity exceeds roughly 4–5 fields, and
- Queries on those fields are infrequent or limited to contained lookups.
Contained lookups may for example check for the existence of a JSON field or include a simple equals,
SELECT id
FROM drawings
WHERE properties ? 'color';
SELECT id
FROM drawings
WHERE properties->>'fill_pattern' = 'solid';
but not operations of higher complexity for which normal columns would be more efficient.
SELECT AVG((properties->>'stroke_width')::numeric)
FROM drawings
WHERE (properties->>'fill_pattern') = 'solid';
Example:
Say you wanted to store optional plant traits.
This could be done in plants.extra_properties JSONB.
erDiagram
plants {
BIGINT id PK "PRIMARY KEY"
TEXT unique_name "NOT NULL, UNIQUE"
TEXT[] common_name_en
TEXT[] common_name_de
TEXT family
JSONB extra_properties "optional, variant/sparse traits"
}
Example contents of extra_properties:
{
"water_need": 3,
"shade_tolerance": 4,
"edible": true,
"toxicity_level": "none",
"soil": { "ph_min": 6.0, "ph_max": 7.5 },
"notes": "Prefers partial shade"
}
Pros:
- Preserves flexibility for polymorphic or sparse data models.
- New or variant-specific fields can be added without changing the table structure.
- Avoids adding lots of columns for entities with many optional fields.
- Minimal migration work from current state. (Only check current use of JSON adheres to new guidelines)
Cons:
- Migration scripts for JSONB fields are more complex than ALTER TABLE operations.
- JSONB migrations modify untyped data inside a blob, so the intent and outcome are less visible than standard column migrations. (Table doesn't change)
- JSONB migrations require more testing work to guarantee the same safety you would get "for free" with typed columns and constraints.
- The risk of data inconsistencies increases because validation and consistency checking move from the database layer (strongly typed columns) to the application layer (runtime deserialization).
- Relies on developer discipline in following agreed-upon guidelines. (Mostly validation practice, rest requires similar discipline as normal columns)
Alternative B: Restrict JSONB to Drawings Only
Keep JSONB usage strictly limited to the drawings table and require normalized columns for all future features.
Example:
Say you wanted to store optional plant traits.
Instead of plants.extra_properties JSONB, create a typed side table:
erDiagram
plants {
INT id PK
TEXT unique_name "NOT NULL"
TEXT[] common_name_en
TEXT[] common_name_de
TEXT family
}
plant_traits {
BIGINT plant_id PK "PRIMARY KEY, FK -> plants.id"
SMALLINT water_need "0..5"
SMALLINT shade_tolerance "0..5"
BOOLEAN edible
TEXT toxicity_level
TEXT notes
}
plants ||--o| plant_traits : "has traits"
Pros:
- Changes are handled through standard, type-safe migrations instead JSON manipulation.
- Ensures stronger data integrity through explicit types, constraints, and foreign keys. (Handled by DB)
- Makes the schema more self-documenting for developers and tools.
Cons:
- Each polymorphic variant requires its own table.
- ORM models have to be changed when changes are made.
- Database schemata have to be changed as data models evolve.
- Tables might become wide with many nullable columns if data has many optional fields.
Alternative C: Phase Out JSONB
Migrate drawings into fully normalized relational structures and only use fully normalized tables in the future.
Example:
How drawings could be structured if we were to implement Alternative C.
erDiagram
drawings {
BIGINT id PK "PRIMARY KEY"
TEXT shape_type "ENUM: rectangle|ellipse|free_line|bezier_polygon|label_text|image"
NUMERIC x
NUMERIC y
NUMERIC rotation
NUMERIC scale_x
NUMERIC scale_y
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
rectangle_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
NUMERIC width
NUMERIC height
TEXT color
TEXT fill_pattern
NUMERIC stroke_width
}
ellipse_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
NUMERIC radius_x
NUMERIC radius_y
TEXT color
TEXT fill_pattern
NUMERIC stroke_width
}
free_line_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
TEXT color
TEXT fill_pattern
NUMERIC stroke_width
}
free_line_point {
BIGINT free_line_id FK "FK -> free_line_props.drawing_id"
INT idx "order in path"
NUMERIC x
NUMERIC y
PK "PRIMARY KEY (free_line_id, idx)"
}
bezier_polygon_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
TEXT color
TEXT fill_pattern
NUMERIC stroke_width
}
bezier_polygon_point {
BIGINT bezier_polygon_id FK "FK -> bezier_polygon_props.drawing_id"
INT idx "order in path"
NUMERIC x
NUMERIC y
NUMERIC cp1x
NUMERIC cp1y
NUMERIC cp2x
NUMERIC cp2y
PK "PRIMARY KEY (bezier_polygon_id, idx)"
}
label_text_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
TEXT text
NUMERIC width
NUMERIC height
TEXT color
}
image_props {
BIGINT drawing_id PK "PRIMARY KEY, FK -> drawings.id"
TEXT path
}
drawings ||--o| rectangle_props : "rectangle data"
drawings ||--o| ellipse_props : "ellipse data"
drawings ||--o| free_line_props : "free line data"
drawings ||--o| bezier_polygon_props : "bezier polygon data"
drawings ||--o| label_text_props : "label text data"
drawings ||--o| image_props : "image data"
free_line_props ||--|{ free_line_point : "points"
bezier_polygon_props ||--|{ bezier_polygon_point : "points"
Pros:
- Changes are handled through standard, type-safe migrations instead JSON manipulation.
- Enforces full schema integrity through explicit types, constraints, and foreign keys. (Handled by DB)
- Makes the schema more self-documenting for developers and tools.
- it is probably about 20% faster
Cons:
- Refactoring the
drawingstable and related backend code into a normalized design would require significant restructuring effort. - Each polymorphic variant requires its own table.
- ORM models have to be changed when changes are made.
- Database schemata have to be changed as data models evolve.
- Tables might become wide with many nullable columns if data has many optional fields. The above solution for
drawingsavoids this by introducing a separate table for each shape.
Decision
It was decided to go with Alternative C: Phase Out JSONB as a typesafe, futureproof option.
Rationale
Implications
- The properties field of
drawingshas to be migrated to a setup using normalized tables for the different shapes.- The new tables have to be added to the database.
- The backend has to be adapted to work with the new database structure.
Related Decisions
Notes
DDL for new Shapes Tables
-- =========================
-- BEZIER POLYGON
-- =========================
CREATE TABLE IF NOT EXISTS public.bezier_polygon_props
(
drawing_id uuid NOT NULL,
color text COLLATE pg_catalog."default",
fill_pattern text COLLATE pg_catalog."default",
stroke_width numeric,
CONSTRAINT "bezier_polygon_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "bezier_polygon_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS public.bezier_polygon_point
(
bezier_polygon_id uuid NOT NULL,
idx integer NOT NULL,
x numeric,
y numeric,
CONSTRAINT "bezier_polygon_point_PK" PRIMARY KEY (bezier_polygon_id, idx),
CONSTRAINT "bezier_polygon_point_FK" FOREIGN KEY (bezier_polygon_id)
REFERENCES public.bezier_polygon_props (drawing_id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
-- =========================
-- FREE LINE
-- =========================
CREATE TABLE IF NOT EXISTS public.free_line_props
(
drawing_id uuid NOT NULL,
color text COLLATE pg_catalog."default",
fill_pattern text COLLATE pg_catalog."default",
stroke_width numeric,
CONSTRAINT "free_line_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "free_line_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS public.free_line_point
(
free_line_id uuid NOT NULL,
idx integer NOT NULL,
x numeric,
y numeric,
CONSTRAINT "free_line_point_PK" PRIMARY KEY (free_line_id, idx),
CONSTRAINT "free_line_point_FK" FOREIGN KEY (free_line_id)
REFERENCES public.free_line_props (drawing_id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
-- =========================
-- RECTANGLE
-- =========================
CREATE TABLE IF NOT EXISTS public.rectangle_props
(
drawing_id uuid NOT NULL,
color text COLLATE pg_catalog."default",
fill_pattern text COLLATE pg_catalog."default",
stroke_width numeric,
width numeric,
height numeric,
CONSTRAINT "rectangle_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "rectangle_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
-- =========================
-- ELLIPSE
-- =========================
CREATE TABLE IF NOT EXISTS public.ellipse_props
(
drawing_id uuid NOT NULL,
color text COLLATE pg_catalog."default",
fill_pattern text COLLATE pg_catalog."default",
stroke_width numeric,
radius_x numeric,
radius_y numeric,
CONSTRAINT "ellipse_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "ellipse_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
-- =========================
-- LABEL TEXT
-- =========================
CREATE TABLE IF NOT EXISTS public.label_text_props
(
drawing_id uuid NOT NULL,
text text COLLATE pg_catalog."default",
color text COLLATE pg_catalog."default",
width numeric,
height numeric,
CONSTRAINT "label_text_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "label_text_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
-- =========================
-- IMAGE
-- =========================
CREATE TABLE IF NOT EXISTS public.image_props
(
drawing_id uuid NOT NULL,
path text COLLATE pg_catalog."default",
CONSTRAINT "image_props_PK" PRIMARY KEY (drawing_id),
CONSTRAINT "image_props_FK" FOREIGN KEY (drawing_id)
REFERENCES public.drawings (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
);
Backfill Data from Properties into new Tables
-- =========================
-- BEZIER POLYGON
-- =========================
BEGIN;
-- (a) Insert per-polygon properties
INSERT INTO bezier_polygon_props (drawing_id, color, fill_pattern, stroke_width)
SELECT id,
properties->>'color',
properties->>'fillPattern',
NULLIF(properties->>'strokeWidth','')::numeric
FROM drawings
WHERE shape_type = 'bezier polygon'
AND (properties ? 'color' OR properties ? 'fillPattern' OR properties ? 'strokeWidth')
ON CONFLICT (drawing_id) DO NOTHING;
-- (b) Insert points
INSERT INTO bezier_polygon_point (bezier_polygon_id, idx, x, y)
SELECT d.id,
p.ordinality - 1 AS idx, -- 0-based index
(p.elem->>0)::numeric AS x,
(p.elem->>1)::numeric AS y
FROM drawings d
JOIN LATERAL jsonb_array_elements(d.properties->'points') WITH ORDINALITY AS p(elem, ordinality)
ON TRUE
WHERE d.shape_type = 'bezier polygon';
-- =========================
-- FREE LINE
-- =========================
INSERT INTO free_line_props (drawing_id, color, fill_pattern, stroke_width)
SELECT id,
properties->>'color',
properties->>'fillPattern',
NULLIF(properties->>'strokeWidth','')::numeric
FROM drawings
WHERE shape_type = 'free line'
ON CONFLICT (drawing_id) DO NOTHING;
INSERT INTO free_line_point (free_line_id, idx, x, y)
SELECT d.id,
p.ordinality - 1 AS idx,
(p.elem->>0)::numeric AS x,
(p.elem->>1)::numeric AS y
FROM drawings d
JOIN LATERAL jsonb_array_elements(d.properties->'points') WITH ORDINALITY AS p(elem, ordinality)
ON TRUE
WHERE d.shape_type = 'free line'
ON CONFLICT DO NOTHING;
-- =========================
-- RECTANGLE
-- =========================
INSERT INTO rectangle_props (drawing_id, color, fill_pattern, stroke_width, width, height)
SELECT id,
properties->>'color',
properties->>'fillPattern',
NULLIF(properties->>'strokeWidth','')::numeric,
NULLIF(properties->>'width','')::numeric,
NULLIF(properties->>'height','')::numeric
FROM drawings
WHERE shape_type = 'rectangle'
ON CONFLICT (drawing_id) DO NOTHING;
-- =========================
-- ELLIPSE
-- =========================
INSERT INTO ellipse_props (drawing_id, color, fill_pattern, stroke_width, radius_x, radius_y)
SELECT id,
properties->>'color',
properties->>'fillPattern',
NULLIF(properties->>'strokeWidth','')::numeric,
NULLIF(properties->>'radiusX','')::numeric,
NULLIF(properties->>'radiusY','')::numeric
FROM drawings
WHERE shape_type = 'ellipse'
ON CONFLICT (drawing_id) DO NOTHING;
-- =========================
-- LABEL TEXT
-- =========================
INSERT INTO label_text_props (drawing_id, text, color, width, height)
SELECT id,
properties->>'text',
properties->>'color',
NULLIF(properties->>'width','')::numeric,
NULLIF(properties->>'height','')::numeric
FROM drawings
WHERE shape_type = 'label text'
ON CONFLICT (drawing_id) DO NOTHING;
-- =========================
-- IMAGE
-- =========================
INSERT INTO image_props (drawing_id, path)
SELECT id,
properties->>'path'
FROM drawings
WHERE shape_type = 'image'
ON CONFLICT (drawing_id) DO NOTHING;
COMMIT;