How to Add a Custom Field Type in Frappe Without Modifying Core

Frappe adds a custom field type

Build a Custom Year Field in Frappe: Meta, ORM, DB, and UI Explained

If you have ever wanted a field type that behaves like a first-class citizen inside Frappe Framework, you already know the obvious problem: Frappe makes it easy to add custom fields, but not brand-new custom field types.

That is where things get interesting. In this guide, we will build a real Year field type that shows up in the form builder, stores data correctly, and renders as a searchable dropdown in forms, all from a custom app. ๐Ÿ› ๏ธ

Important: this approach works by patching internal Frappe APIs in Frappe v15.x. It is powerful, but it is not an officially documented extension point, so you should expect to re-check these internals when upgrading.

โฑ๏ธ Time to Complete

About 35 to 50 minutes, depending on how familiar you are with Bench, app hooks, and Frappe's client-side controls.

๐ŸŽฏ What you'll achieve

By the end of this article, you will know how to:

  • Add a brand-new Year field type to Frappe from a custom app
  • Make it appear in the form builder's field type dropdown
  • Store extra configuration such as past_years and future_years
  • Render the field as a searchable autocomplete input in forms
  • Keep everything outside Frappe core so upgrades stay manageable

Why this is harder than it looks

Creating a new field type in Frappe is not a single change. Several parts of the framework need to agree that your field type exists:

LayerWhy it matters
Python field type registryTells the ORM this field stores real data
Database type mapTells MariaDB or PostgreSQL what column type to create
DocField metadataMakes the field type selectable inside the form builder
Client-side metadataKeeps the browser-side field type list in sync
JavaScript control classRenders the field properly inside forms

If one layer is missing, you usually get a confusing failure:

  • The field type does not appear in the dropdown
  • The form saves, but the column is never created
  • The field appears in meta, but renders as a broken input
  • Custom properties exist in the database, but the ORM ignores them

That is why this tutorial takes the long route and wires all of it together properly.


What we are building

We will create a custom field type called Year with these behaviors:

  • It appears in the DocType form builder alongside Frappe's built-in field types
  • It stores the selected year as a database value
  • It exposes two extra properties:
    • past_years
    • future_years
  • It renders as a searchable autocomplete dropdown, similar to a lightweight lookup field
  • It calculates its available year list dynamically from the current year

Example:

If the current year is 2026, and you set:

  • past_years = 2
  • future_years = 2

The dropdown will offer:

2024, 2025, 2026, 2027, 2028


Project structure

Assume your custom app is named my_app.

my_app/
โ”œโ”€โ”€ my_app/
โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”œโ”€โ”€ hooks.py
โ”‚   โ”œโ”€โ”€ utils/
โ”‚   โ”‚   โ””โ”€โ”€ year_fieldtype.py
โ”‚   โ””โ”€โ”€ public/
โ”‚       โ””โ”€โ”€ js/
โ”‚           โ”œโ”€โ”€ my_app.bundle.js
โ”‚           โ””โ”€โ”€ year_control.js

You will modify or create only these files:

  • my_app/my_app/__init__.py
  • my_app/my_app/hooks.py
  • my_app/my_app/utils/year_fieldtype.py
  • my_app/my_app/public/js/year_control.js
  • my_app/my_app/public/js/my_app.bundle.js

Clean, isolated, and upgrade-friendly. โœ…


Step 1: Register the field type in Python

Frappe keeps a tuple called data_fieldtypes in frappe.model. This is the list of field types that are treated as real data columns, not layout-only fields such as Section Break or Column Break.

If your custom type is not added here, Frappe will not treat it as a data-bearing field.

File: my_app/my_app/__init__.py

import frappe.model as _model

if "Year" not in _model.data_fieldtypes:
    _model.data_fieldtypes = _model.data_fieldtypes + ("Year",)

Why this works

  • data_fieldtypes is a tuple, so you cannot .append() to it
  • Your app's __init__.py is imported early, before Frappe starts using the field definitions

This makes Year visible to the ORM early enough to matter.


Step 2: Patch the database type maps

Once Frappe accepts Year as a data field, it still needs to know what SQL type to use when creating or altering columns.

For this example, Year is stored as a short string like "2026", so mapping it to varchar is a practical choice.

Still in my_app/my_app/__init__.py, add:

from frappe.database.mariadb.database import MariaDBDatabase as _MDB
from frappe.database.postgres.database import PostgresDatabase as _PG

_orig_mariadb_setup = _MDB.setup_type_map
_orig_postgres_setup = _PG.setup_type_map


def _patched_mariadb_setup(self):
    _orig_mariadb_setup(self)
    if "Year" not in self.type_map:
        self.type_map["Year"] = ("varchar", self.VARCHAR_LEN)


def _patched_postgres_setup(self):
    _orig_postgres_setup(self)
    if "Year" not in self.type_map:
        self.type_map["Year"] = ("varchar", self.VARCHAR_LEN)


_MDB.setup_type_map = _patched_mariadb_setup
_PG.setup_type_map = _patched_postgres_setup

Why patch both databases?

Even if your site currently runs on MariaDB, Frappe v15 also supports PostgreSQL. Patching both keeps the custom field type portable across environments.


Step 3: Understand the "special DocType" trap

This is the part most people miss.

Frappe treats a handful of doctypes, including DocField, as special doctypes. Those doctypes do not go through the normal metadata processing flow. In practice, that means runtime behaviors like Property Setters and Custom Fields do not always apply to them the way you expect.

Why that matters here:

  • The fieldtype dropdown in the form builder comes from DocField metadata
  • Our extra properties such as past_years and future_years also need to behave like real fields on DocField

So a simple Property Setter is not enough. We need to patch metadata processing and inject our changes after Frappe finishes its normal work.


Step 4: Patch Meta.process() to inject the field type and extra properties

Continue inside my_app/my_app/__init__.py:

from frappe.model.meta import Meta as _Meta

_orig_meta_process = _Meta.process


def _patched_meta_process(self):
    _orig_meta_process(self)

    if self.name == "DocField":
        ft_field = next(
            (f for f in self.get("fields") if f.fieldname == "fieldtype"), None
        )
        if ft_field and ft_field.options and "Year" not in ft_field.options.split("\n"):
            opts = ft_field.options.split("\n")
            opts.append("Year")
            opts.sort()
            ft_field.options = "\n".join(opts)

        from frappe.model.base_document import BaseDocument

        existing = {f.fieldname for f in self.get("fields")}
        added = False

        for fname, label in (
            ("past_years", "Past Years"),
            ("future_years", "Future Years"),
        ):
            if fname not in existing:
                self.append(
                    "fields",
                    BaseDocument(
                        {
                            "doctype": "DocField",
                            "fieldname": fname,
                            "fieldtype": "Int",
                            "label": label,
                            "options": "",
                            "default": "",
                            "depends_on": 'eval:doc.fieldtype==="Year"',
                            "hidden": 0,
                            "reqd": 0,
                            "read_only": 0,
                            "permlevel": 0,
                            "parent": "DocField",
                            "parenttype": "DocType",
                            "parentfield": "fields",
                        }
                    ),
                )
                added = True

        if added:
            for attr in ("_fields", "_valid_fields", "_valid_columns"):
                try:
                    delattr(self, attr)
                except AttributeError:
                    pass


_Meta.process = _patched_meta_process

What this patch is doing

It handles two jobs:

  1. Adds Year to the server-side fieldtype options on DocField
  2. Injects past_years and future_years into DocField metadata so the ORM can see and save them

Why cache invalidation matters

Frappe builds cached metadata structures during processing. If you append new fields after those caches are built, the new fields exist in the list but not in the computed lookup structures.

That is why deleting:

  • _fields
  • _valid_fields
  • _valid_columns

is important. It forces Frappe to rebuild those caches with the injected fields included.


Step 5: Create the after-migrate setup script

Now we need the persistent setup:

  • Property Setters so the field type appears where expected
  • Custom Fields so the extra settings show in the UI
  • Physical database columns on tabDocField

Create my_app/my_app/utils/year_fieldtype.py:

import frappe

_TARGET_DOCTYPES = ("DocField", "Customize Form Field", "Custom Field")
_CUSTOM_FIELD_TARGETS = ("DocField", "Customize Form Field")

_YEAR_CUSTOM_FIELDS = [
    {
        "fieldname": "past_years",
        "label": "Past Years",
        "fieldtype": "Int",
        "default": "30",
        "description": "Number of past years to show (default 30)",
        "depends_on": 'eval:doc.fieldtype==="Year"',
        "insert_after": "sort_options",
    },
    {
        "fieldname": "future_years",
        "label": "Future Years",
        "fieldtype": "Int",
        "default": "10",
        "description": "Number of future years to show (default 10)",
        "depends_on": 'eval:doc.fieldtype==="Year"',
        "insert_after": "past_years",
    },
]


def _ensure_year_in_fieldtype_options():
    for dt in _TARGET_DOCTYPES:
        meta = frappe.get_meta(dt)
        fieldtype_df = meta.get_field("fieldtype")
        if not fieldtype_df:
            continue

        current_options = fieldtype_df.options or ""
        options_list = [o for o in current_options.split("\n") if o]

        if "Year" in options_list:
            continue

        options_list.append("Year")
        options_list.sort()
        new_options = "\n".join(options_list)

        existing = frappe.db.exists(
            "Property Setter",
            {"doc_type": dt, "field_name": "fieldtype", "property": "options"},
        )

        if existing:
            frappe.db.set_value("Property Setter", existing, "value", new_options)
        else:
            ps = frappe.new_doc("Property Setter")
            ps.doctype_or_field = "DocField"
            ps.doc_type = dt
            ps.field_name = "fieldtype"
            ps.property = "options"
            ps.property_type = "Small Text"
            ps.value = new_options
            ps.is_system_generated = 0
            ps.insert(ignore_permissions=True)


def _ensure_year_custom_fields():
    for dt in _CUSTOM_FIELD_TARGETS:
        for cf_def in _YEAR_CUSTOM_FIELDS:
            fname = cf_def["fieldname"]
            if frappe.db.exists("Custom Field", {"dt": dt, "fieldname": fname}):
                continue

            cf = frappe.new_doc("Custom Field")
            cf.dt = dt
            cf.fieldname = fname
            cf.label = cf_def["label"]
            cf.fieldtype = cf_def["fieldtype"]
            cf.default = cf_def["default"]
            cf.description = cf_def["description"]
            cf.depends_on = cf_def["depends_on"]
            cf.insert_after = cf_def["insert_after"]
            cf.is_system_generated = 0
            cf.insert(ignore_permissions=True)

    from frappe.database.schema import add_column

    added = False
    for cf_def in _YEAR_CUSTOM_FIELDS:
        fname = cf_def["fieldname"]
        if not frappe.db.has_column("DocField", fname):
            add_column("DocField", fname, "Int")
            added = True

    if added:
        cache_key = "table_columns::tabDocField"
        frappe.cache.delete_value(cache_key)
        frappe.client_cache.delete_value(cache_key)


def setup_year_fieldtype():
    _ensure_year_in_fieldtype_options()
    _ensure_year_custom_fields()
    frappe.db.commit()

Why this script exists

This is the "make it real" step.

  • The __init__.py patches solve runtime behavior
  • The after_migrate script makes sure the database and metadata records exist consistently after migration

It is also safe to run repeatedly, which is exactly what you want from a migration-time setup routine. ๐Ÿ”


Step 6: Hook the setup into migration

Open my_app/my_app/hooks.py and add:

after_migrate = [
    "my_app.utils.year_fieldtype.setup_year_fieldtype",
]

If you already have an after_migrate list, append this entry instead of replacing the whole thing.


Step 7: Create the JavaScript control

Now we teach the browser how to render the new field.

Create my_app/my_app/public/js/year_control.js:

const _YEAR_TARGETS = new Set([
  "DocField",
  "Customize Form Field",
  "Custom Field",
]);

$(document).on("app_ready", function () {
  if (
    frappe.model &&
    frappe.model.data_fieldtypes &&
    !frappe.model.data_fieldtypes.includes("Year")
  ) {
    frappe.model.data_fieldtypes.push("Year");
  }

  const _original_get_meta = frappe.get_meta;
  frappe.get_meta = function (doctype) {
    let meta = _original_get_meta.call(this, doctype);
    if (meta && _YEAR_TARGETS.has(doctype)) {
      _inject_year_into_meta(meta);
    }
    return meta;
  };

  frappe.ui.form.ControlYear = class ControlYear extends (
    frappe.ui.form.ControlAutocomplete
  ) {
    static trigger_change_on_input_event = false;

    set_options() {
      const current_year = new Date().getFullYear();
      const past = cint(this.df.past_years) || 30;
      const future = cint(this.df.future_years) || 10;

      const start = current_year - past;
      const end = current_year + future;

      let options = [];
      for (let year = end; year >= start; year--) {
        options.push({ label: String(year), value: String(year) });
      }

      this.set_data(options);
    }
  };

  _patchFormBuilder();
});

function _patchFormBuilder() {
  function patch(FB) {
    if (!FB || FB._yearPatched) return;

    const _origSetup = FB.prototype.setup_app;
    FB.prototype.setup_app = function () {
      _origSetup.call(this);

      try {
        const ctx = this.$form_builder.$.appContext;
        if (ctx && !ctx.components.YearControl) {
          ctx.components.YearControl = {
            props: ["df", "value", "read_only"],
            template: `
                            <div class="control frappe-control"
                                 :class="{ editable: $slots.label }">
                                <div v-if="$slots.label" class="field-controls">
                                    <slot name="label" />
                                    <slot name="actions" />
                                </div>
                                <div v-else class="control-label label"
                                     :class="{ reqd: df.reqd }">
                                    {{ df.label }}
                                </div>
                                <input class="form-control" type="text" readonly />
                                <div v-if="df.description" class="mt-2 description"
                                     v-html="df.description" />
                            </div>`,
          };
        }
      } catch (e) {
        console.warn("Year field: could not register form-builder preview", e);
      }
    };

    FB._yearPatched = true;
  }

  if (frappe.ui && frappe.ui.FormBuilder) {
    patch(frappe.ui.FormBuilder);
    return;
  }

  let _fb;
  Object.defineProperty(frappe.ui, "FormBuilder", {
    get() {
      return _fb;
    },
    set(val) {
      _fb = val;
      patch(val);
    },
    configurable: true,
    enumerable: true,
  });
}

function _inject_year_into_meta(meta) {
  if (!meta || !meta.fields) return;

  let ft_field = meta.fields.find((f) => f.fieldname === "fieldtype");
  if (
    ft_field &&
    ft_field.options &&
    !ft_field.options.split("\n").includes("Year")
  ) {
    let opts = ft_field.options.split("\n");
    opts.push("Year");
    opts.sort();
    ft_field.options = opts.join("\n");
  }

  if (!meta.fields.find((f) => f.fieldname === "past_years")) {
    meta.fields.push(
      {
        fieldname: "past_years",
        label: __("Past Years"),
        fieldtype: "Int",
        default: "30",
        description: __("Number of past years to show (default 30)"),
        depends_on: 'eval:doc.fieldtype==="Year"',
      },
      {
        fieldname: "future_years",
        label: __("Future Years"),
        fieldtype: "Int",
        default: "10",
        description: __("Number of future years to show (default 10)"),
        depends_on: 'eval:doc.fieldtype==="Year"',
      },
    );
  }
}

Why ControlAutocomplete is a good base

This choice gives you a lightweight searchable input instead of forcing users through a plain dropdown. That is a better experience once the year range grows large.

It also matches the goal of making the field feel like a native Frappe control rather than a custom hack.


Step 8: Add the control to your app bundle

Open my_app/my_app/public/js/my_app.bundle.js and add:

import "./year_control.js";

Also make sure your hooks.py includes:

app_include_js = "my_app.bundle.js"

Without this, the browser will never load your control.


Step 9: Build and migrate

From your bench directory, run:

bench build --app my_app
bench migrate

What should happen next

After the build and migration finish:

  1. Open a DocType in the form builder
  2. Add a new field
  3. Choose Year as the field type
  4. Select that field and confirm you can see:
    • Past Years
    • Future Years
  5. Save the DocType
  6. Open a document using that DocType and confirm the field renders as a searchable year dropdown

If that works, the custom field type is fully wired through the stack. ๐ŸŽ‰


How all the pieces fit together

Here is the mental model that makes this setup easier to reason about:

On Python startup

  • Your app imports
  • Year is added to frappe.model.data_fieldtypes
  • MariaDB and PostgreSQL type maps learn how to store Year
  • Meta.process() is patched so DocField can recognize the new field type and extra properties

During migration

  • Property Setters keep the field type available in the right doctypes
  • Custom Fields create the editable property UI
  • tabDocField gets real columns for past_years and future_years

On browser load

  • The client-side field type registry learns about Year
  • frappe.get_meta() is patched to inject the same metadata client-side
  • ControlYear becomes available for form rendering
  • The form builder preview gets a YearControl component

When a form renders

  • Frappe sees a field with type Year
  • It creates frappe.ui.form.ControlYear
  • set_options() computes the allowed year range
  • The user gets a searchable, friendly input instead of a broken or fallback control

Practical advice before you use this in production

This pattern works, but it is advanced. Before you ship it widely, keep these points in mind:

  • Treat it as an internal integration. Frappe does not currently present custom field types as a stable public plugin API.
  • Re-test after framework upgrades. The metadata flow, form builder internals, or control registration path may change between versions.
  • Keep the implementation isolated. Doing everything in your own app is the right move because it keeps core diffs out of the equation.
  • Prefer simple storage. If your field can safely map to existing SQL types like varchar, int, or date, maintenance becomes easier.

If your use case only needs custom behavior on top of an existing field, extending a built-in field type may still be cheaper than inventing a brand-new one.


How to adapt this pattern for other custom field types

Once you understand the Year example, you can reuse the same architecture for other controls.

If your field needs no extra properties

You can simplify the setup:

  • Add the type to data_fieldtypes
  • Patch the database type map
  • Inject the type into DocField metadata
  • Create the JavaScript control
  • Bundle it and run migrations

Good base classes to extend

Field behaviorGood base class
Searchable inputControlAutocomplete
Plain textControlData
Numeric inputControlInt or ControlFloat
Static dropdownControlSelect
Date pickerControlDate
Rich textControlTextEditor

Good SQL mappings

Data shapeCommon mapping
Short text("varchar", self.VARCHAR_LEN)
Long text("longtext", None)
Integer("int", 11)
Decimal("decimal", "21,9")
Date("date", None)
Boolean-ish flag("int", 1)

Final checklist

  • Added Year to frappe.model.data_fieldtypes
  • Patched MariaDB and PostgreSQL type maps
  • Patched Meta.process() for DocField
  • Injected past_years and future_years server-side
  • Created the after_migrate setup function
  • Added the setup function to hooks.py
  • Created ControlYear on the client side
  • Patched client-side metadata injection
  • Registered a form builder preview component
  • Imported year_control.js into your app bundle
  • Ran bench build --app my_app
  • Ran bench migrate

Useful links

If you want to explore the stack behind this article, these are worth bookmarking:


Closing thought

Adding a custom field type in Frappe is absolutely possible, but it is not a one-file customization. You are stitching together metadata, database behavior, ORM expectations, and client-side rendering.

That sounds intimidating at first, but once you see the shape of the system, it becomes surprisingly logical.

If you can get one field type like Year working cleanly, you now have a blueprint for building more ambitious controls without forking Frappe core. That is a useful capability for any serious Frappe developer. ๐Ÿš€

Related posts