# How Custom Issue Properties and Workspace-Level States Work in Plane's Data Model

> Understand Plane's data model for custom issue properties and workspace-level states. Learn how JSON schema and state groups manage your workflows efficiently.

- Repository: [Plane/plane](https://github.com/makeplane/plane)
- Tags: internals
- Published: 2026-06-22

---

**Plane stores custom issue properties as JSON-schema definitions in the `IssueProperty` model with concrete values persisted in `IssueVersion.properties`, while workspace-level states are managed through the `State` model using `StateGroup` enums for workflow grouping.**

Plane is an open-source project management platform that allows teams to extend issue tracking beyond default fields. This article examines how the codebase handles custom issue properties and workspace-level states through Django models, JSON storage, and TypeScript adapters, based on the source code in the `makeplane/plane` repository.

## Custom Issue Properties Architecture

Custom issue properties allow workspaces to define arbitrary fields for issues beyond the standard title, description, and assignee. The architecture separates the **schema definition** from the **concrete values**, enabling flexible data structures while maintaining query performance.

### Defining Custom Property Schemas

The `IssueProperty` model in [`apps/api/plane/db/models/issueproperty.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issueproperty.py) defines the structure of custom fields. Each property belongs to a specific workspace and optionally to a project, allowing for scoped field definitions.

Key fields in the `IssueProperty` model include:

- `id` – UUID primary key
- `properties` – JSON field storing the schema (type, name, required flag, description)
- `workspace` – Foreign key to the owning workspace
- `project` – Foreign key for project-scoped properties
- `created_by` / `updated_by` – Audit trail fields

When an admin creates a custom property via `POST /api/workspaces/:workspace_id/issue-properties/`, the `IssuePropertySerializer` validates and stores the JSON schema in the `properties` column of the `issue_property` table, as defined in [`apps/api/plane/db/migrations/0001_initial.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/migrations/0001_initial.py) (lines 1490–1665).

### Storing Concrete Property Values

Concrete values for custom properties are stored in the `IssueVersion` model located in [`apps/api/plane/db/models/issue.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issue.py) at line 71. The model defines:

```python
properties = models.JSONField(default=dict)

```

This JSON field holds a mapping of `property_key → value`, where keys are prefixed with `customproperty_` followed by the property UUID (e.g., `customproperty_12345`). When `IssueVersion.log_issue_version()` is called during issue updates, it captures the current state of all custom properties in this JSON payload.

### Frontend Integration and Filtering

The frontend consumes custom properties through a type-safe layer defined in [`packages/types/src/issues/issue-property-values.ts`](https://github.com/makeplane/plane/blob/main/packages/types/src/issues/issue-property-values.ts). The `TIssuePropertyValues` type defines the shape of the property values map, while `TIssuePropertyValueErrors` handles validation states.

The `IssuePropertyLabels` component in [`apps/web/core/components/issues/workspace-draft/draft-issue-properties.tsx`](https://github.com/makeplane/plane/blob/main/apps/web/core/components/issues/workspace-draft/draft-issue-properties.tsx) renders form controls based on the schema retrieved from the API. When users modify values, the `issuePropertyValues` state (managed in [`issue-modal-context.tsx`](https://github.com/makeplane/plane/blob/main/issue-modal-context.tsx)) updates, and the payload is sent to the backend under the `properties` key.

For filtering, the shared-state adapter in [`packages/shared-state/src/store/work-item-filters/adapter.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/work-item-filters/adapter.ts) (line 170) detects keys starting with `customproperty_` and constructs appropriate queries, enabling custom fields to function identically to built-in filters.

## Workspace-Level States Management

Workspace-level states represent customizable workflow stages (e.g., *Backlog*, *In Progress*, *Done*) that issues can transition through. Unlike static enums, these are first-class database entities that projects can extend.

### The State Model and StateGroup Enum

The `State` model in [`apps/api/plane/db/models/state.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/state.py) defines workflow stages with the following key fields:

- `id` – UUID primary key
- `name` – Human-readable label
- `color` – Hex color for UI rendering
- `slug` – URL-safe identifier
- `sequence` – Integer for ordering in dropdowns and boards
- `group` – Choice field from `StateGroup` enum (Backlog, Unstarted, Started, Completed, Cancelled, Triage)
- `is_triage` – Boolean flag for special triage behavior
- `default` – Boolean indicating the default state for new issues

The `StateGroup` enum (defined in the same file) enables the UI to group states conceptually. For example, multiple custom states might map to the `Started` group for reporting purposes.

### Default States and Managers

The system seeds six default states via the `DEFAULT_STATES` constant in [`state.py`](https://github.com/makeplane/plane/blob/main/state.py) (lines 24–62): Backlog, Todo, In Progress, Done, Cancelled, and Triage. When a new project is created, these rows are inserted into the `states` table.

The model implements two custom managers:

- `StateManager` (exposed as `objects`) – Excludes triage states from standard queries
- `TriageStateManager` (exposed as `triage_objects`) – Returns only triage states

This separation allows the API to use `State.objects` for normal UI lists while the triage board uses `triage_objects` for specialized workflows.

### State Transitions and Lifecycle

When an issue changes state, the system performs several operations:

1. **Foreign Key Update** – The `state_id` column on the `Issue` model updates to reference the new `State` row
2. **Timestamp Synchronization** – The `Issue._sync_completed_at()` method (in [`issue.py`](https://github.com/makeplane/plane/blob/main/issue.py) line 40) automatically sets `completed_at` when the state moves into the `COMPLETED` group
3. **Version Logging** – `IssueVersion.log_issue_version()` copies the current state ID into the version row's `state` field, preserving historical workflow stages

The frontend retrieves states via `GET /api/projects/:project_id/states/`, ordered by the `sequence` field, and groups Kanban board columns by the `group` enum value.

## Implementation Examples

### Creating Custom Properties via API

```typescript
// Frontend request to create a custom property
await api.post(`/api/workspaces/${workspaceSlug}/issue-properties/`, {
  properties: {
    title: "Sprint Number",
    type: "number",
    required: false,
    description: "Which sprint this issue belongs to"
  }
});

```

This request hits the serializer in [`apps/api/plane/serializer/issue_property.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/serializer/issue_property.py), creating a row in the `issue_property` table with the provided JSON schema.

### Rendering Custom Property Fields

```tsx
import { IssuePropertyLabels } from "./properties/labels";

export const IssueForm = observer(({ issue }) => {
  const { issuePropertyValues, setIssuePropertyValues } = useIssueModal();

  return (
    <IssuePropertyLabels
      propertyValues={issuePropertyValues}
      onChange={(key, value) => 
        setIssuePropertyValues(prev => ({ ...prev, [key]: value }))
      }
    />
  );
});

```

The `IssuePropertyLabels` component iterates over the schema returned from the API and renders appropriate controls (text inputs, number fields, select dropdowns) based on the `type` specified in the property definition.

### Saving Issues with Custom Properties

```typescript
await api.patch(`/api/issues/${issueId}/`, {
  name: "Update landing page",
  description_html: "<p>New copy…</p>",
  properties: {
    customproperty_12345: 7,               // Sprint Number
    customproperty_67890: "High priority" // Custom select field
  }
});

```

The `properties` object is stored verbatim in `IssueVersion.properties` according to the model definition in [`apps/api/plane/db/models/issue.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issue.py) at line 71.

### Filtering by Custom Properties

```typescript
// Using the shared-state filter adapter
filterStore.addFilter({
  property: "customproperty_12345",
  operator: "is",
  value: "7"
});

```

The adapter in [`packages/shared-state/src/store/work-item-filters/adapter.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/work-item-filters/adapter.ts) detects the `customproperty_` prefix and constructs the correct query parameters for the backend filtering endpoint.

### Adding Workspace States

```typescript
await api.post(`/api/projects/${projectId}/states/`, {
  name: "Ready for Review",
  color: "#6B5B95",
  group: "started",  // Must match StateGroup enum values
  default: false
});

```

After creation, issues can transition to this state by updating their `state_id` foreign key. The state appears in dropdowns ordered by its `sequence` value relative to other states in the project.

## Key Files Reference

| File | Role |
|------|------|
| [`apps/api/plane/db/models/issueproperty.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issueproperty.py) | Django model defining custom property schemas |
| [`apps/api/plane/db/models/state.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/state.py) | State model, StateGroup enum, and default seeds (lines 14–62) |
| [`apps/api/plane/db/models/issue.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issue.py) | IssueVersion.properties JSON field (line 71) and `_sync_completed_at()` method |
| [`apps/api/plane/db/migrations/0001_initial.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/migrations/0001_initial.py) | Database migration creating `issue_property` and `states` tables |
| [`packages/types/src/issues/issue-property-values.ts`](https://github.com/makeplane/plane/blob/main/packages/types/src/issues/issue-property-values.ts) | TypeScript interfaces for property values |
| [`apps/web/core/components/issues/workspace-draft/draft-issue-properties.tsx`](https://github.com/makeplane/plane/blob/main/apps/web/core/components/issues/workspace-draft/draft-issue-properties.tsx) | UI component rendering custom property inputs |
| [`packages/shared-state/src/store/work-item-filters/adapter.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/work-item-filters/adapter.ts) | Filter logic recognizing `customproperty_` prefixes (line 170) |
| [`packages/shared-state/src/store/workspace.store.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/workspace.store.ts) | Client-side caching of workspace states |

## Summary

- **Custom issue properties** are defined by the `IssueProperty` model storing JSON schemas in [`apps/api/plane/db/models/issueproperty.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issueproperty.py), with concrete values saved in `IssueVersion.properties` as JSON with `customproperty_` prefixed keys.
- **Workspace-level states** are managed through the `State` model in [`apps/api/plane/db/models/state.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/state.py), using the `StateGroup` enum to categorize workflow stages into logical groups (Backlog, Started, Completed, etc.).
- The frontend filters custom properties via the adapter in [`packages/shared-state/src/store/work-item-filters/adapter.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/work-item-filters/adapter.ts), treating prefixed keys identically to built-in fields.
- State transitions trigger automatic timestamp updates through `Issue._sync_completed_at()` and are versioned in `IssueVersion.log_issue_version()` for historical tracking.
- Both systems use project-scoped foreign keys while maintaining workspace-level consistency, allowing flexible customization without sacrificing query performance.

## Frequently Asked Questions

### How does Plane store custom property values without database schema migrations?

Plane uses a **JSONField** in the `IssueVersion` model ([`apps/api/plane/db/models/issue.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/issue.py) line 71) to store custom property values as a dictionary with keys prefixed by `customproperty_`. This schemaless approach allows workspaces to define arbitrary fields without altering the database schema, while the `IssueProperty` model stores the validation schema separately.

### What is the difference between State and StateGroup in Plane's data model?

The **State** model represents individual workflow stages (e.g., "Code Review" or "Testing") with unique UUIDs, colors, and sequences specific to each project. **StateGroup** is an enum defined in [`apps/api/plane/db/models/state.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/state.py) that categorizes states into six fixed groups: Backlog, Unstarted, Started, Completed, Cancelled, and Triage. This grouping allows the UI to organize custom states into standard Kanban columns while preserving specific workflow names.

### How does Plane handle the triage state differently from other states?

The `State` model includes a custom `StateManager` that excludes triage states from standard queries (`objects`), while a separate `TriageStateManager` (`triage_objects`) retrieves only triage states. According to [`apps/api/plane/db/models/state.py`](https://github.com/makeplane/plane/blob/main/apps/api/plane/db/models/state.py) (lines 65–78), this separation ensures that issues in triage appear only in specialized views unless explicitly requested, maintaining clean separation between standard workflow and intake queues.

### Can custom properties be filtered the same way as built-in issue fields?

Yes. The shared-state filter adapter in [`packages/shared-state/src/store/work-item-filters/adapter.ts`](https://github.com/makeplane/plane/blob/main/packages/shared-state/src/store/work-item-filters/adapter.ts) (line 170) treats any filter key starting with `customproperty_` as a first-class filter field. The frontend constructs filter queries using the same operators (is, is_not, etc.) for custom properties as for built-in fields like assignee or priority, with the backend querying the JSON values directly.