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

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 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 (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 at line 71. The model defines:

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. 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 renders form controls based on the schema retrieved from the API. When users modify values, the issuePropertyValues state (managed in 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 (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 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 (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 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

// 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, creating a row in the issue_property table with the provided JSON schema.

Rendering Custom Property Fields

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

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 at line 71.

Filtering by Custom Properties

// 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 detects the customproperty_ prefix and constructs the correct query parameters for the backend filtering endpoint.

Adding Workspace States

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 Django model defining custom property schemas
apps/api/plane/db/models/state.py State model, StateGroup enum, and default seeds (lines 14–62)
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 Database migration creating issue_property and states tables
packages/types/src/issues/issue-property-values.ts TypeScript interfaces for property values
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 Filter logic recognizing customproperty_ prefixes (line 170)
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, 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, 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, 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 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 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 (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 (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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →