Content Types and Fields

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Everything an editor sees in Drupal, from an exhibition page to a user profile, is a content entity whose data lives in fields. Content types are the most visible way to group those fields, and the Field UI (the Manage fields, Manage form display and Manage display screens) is how a site builder defines them without writing code. This page explains the model underneath the screens: entity types, bundles and fields; the difference between field storage and a field instance; every setting the Field UI offers; which database tables hold the values; and exactly which configuration files each click creates. Understanding that last part is what lets you move a content model between environments with configuration management, automate it with Drush or a recipe, and recognise the problems that appear when the model changes after content exists.

Facts on this page were checked against the Drupal User Guide, the API reference and the Drush documentation on 2026-10-30. The rest of this sub-section is written against Drupal 11.4.x, Drush 13/14 and PHP 8.3 or later. The examples use the fictitious Meridian Museum site introduced in the sub-section index: the exhibition and event content types built in this page are reused by the meridian_events recipe, by the views, the JSON:API kiosk and the custom collection_object entity covered in Custom entity types.

Entities, entity types, bundles and fields

Drupal uses four related words that older tutorials often blur together. Keeping them apart makes the rest of the page, and the Entity API, much easier to follow.

Term Meaning Meridian Museum example

Entity type

A kind of thing that the system can store, defined by a PHP class and an annotation or attribute. Content entity types store data in the database through fields. Config entity types store exported configuration.

node (content item), user, taxonomy_term, media, comment, block_content, and the custom collection_object

Bundle

A subtype of an entity type. Every entity belongs to exactly one bundle, and the bundle decides which configurable fields it has. The User Guide calls it an "entity subtype". For nodes the bundle is the content type; for terms it is the vocabulary; for media it is the media type; for custom blocks the block type.

exhibition, event, news, page (node bundles); topic, gallery (term bundles); image, document, remote_video (media bundles)

Field

One named piece of data on an entity, made of one or more properties. A field has a type (what it stores), a widget (how it is edited) and a formatter (how it is shown).

field_dates (a date range), field_ticket_url (a link), field_hero (a media reference)

Field item

One value of a field. A field with cardinality greater than one holds an ordered list of items, each with its own delta.

The three topic terms attached to one exhibition

The User Guide introduces the idea as "Concept: Content Entities and Fields" and then builds a first content type and its fields in chapter 6 ("Setting Up Content Structure"). Its wording is deliberately friendly: it speaks of "entity subtypes" and never uses the developer word bundle. The API documentation, the machine names in configuration files and the Entity API all use "bundle", so this page uses both words for the same thing.

Not every entity type has bundles. A custom entity type can declare a single implicit bundle (the bundle equals the entity type ID), as the collection_object entity does until a bundle config entity is added. Whether bundles are configuration entities (a content type is a node_type config entity, a vocabulary is a taxonomy_vocabulary) is a design choice of the entity type, not a rule.

Content entities versus configuration entities

Two families of entities exist, and the distinction determines where things are stored.

  • Content entities (ContentEntityBase subclasses) hold content: nodes, users, terms, media, comments, files. They live in the database, are revisionable and translatable when the entity type opts in, and are different on every environment. Their data is never exported with config:export.

  • Configuration entities (ConfigEntityBase subclasses) hold site building decisions: content types, field storages, field instances, displays, views, roles, text formats. They live in the config table, are exported to YAML files and are expected to be identical on every environment.

A content type is itself a config entity. The exhibition bundle you create in the UI is exported as node.type.exhibition, and every field you add to it creates further config entities. The content created from it (exhibition pages) is stored as node content entities. This split between the model (configuration) and the data (content) is the reason that moving a model forward is a configuration deployment, while moving content needs migrations or a content synchronisation tool.

Entity type node with bundles exhibition and event, field instances, a shared field storage and the form and view displays

The figure shows the five layers described in the rest of the page. An entity type owns base fields and has bundles. Each bundle gets field instances; every instance points to a field storage that is shared by all bundles of the same entity type that use the same field name. Each bundle also has a form display and a view display that decide the widget and the formatter of every instance.

Creating the exhibition content type

Meridian Museum editors publish exhibitions (with a run of dates, a hero image, a gallery and topics) and events (with a date range, a ticket link and a capacity). Since Drupal 11.4 the Standard installation profile no longer creates the Article and Page content types, so a new site starts with no content types at all and tutorials that begin with "open the Article content type" no longer work. Create the types yourself, as in the User Guide’s section 6.1, "Adding a Content Type".

The Add content type form

Go to Manage > Structure > Content types (/admin/structure/types) and choose Add content type. The form has these parts.

Setting Effect

Name and Machine name

The label and the immutable machine name. The machine name becomes the bundle ID, the suffix of node.type.exhibition and a part of every field instance and display ID. Pick it carefully: renaming means deleting and recreating the type. Machine names use lowercase letters, digits and underscores, up to 32 characters.

Description

Shown on the Add content page to help editors choose a type.

Submission form settings: title field label, preview before submitting, explanation or submission guidelines

The label of the built-in title base field on this bundle (the base field is overridden for the bundle, see Base fields versus configurable fields), whether a Preview button is offered (preview_mode: disabled, optional, required) and the help text printed on the form.

Publishing options

Default values for new nodes of this bundle: Published, Promoted to front page, Sticky at top of lists, and Create new revision (new_revision). These are default values of base fields, stored as base field overrides.

Display settings

Display author and date information (display_submitted), which controls the "Submitted by …​ on …​" line in the default node templates.

Menu settings

The menus an editor may add a node of this type to from the node form (the Menu UI module). For exhibition the Meridian site allows only the main menu; see Menus and path aliases.

Click Save and manage fields. Drupal now has a bundle with no configurable fields: every node still has the base fields title, status, uid, created, changed, promote and sticky (plus the revision metadata fields), which is why the Create form is not empty even before you add a field.

Repeat for event, with the description "A single dated event at the museum, such as a talk or a workshop", the title label "Event name" and Create new revision enabled.

The exported content type

Once saved, the type exists as one configuration object. This is what drush config:export writes:

# config/sync/node.type.exhibition.yml
langcode: en
status: true
dependencies: {  }
name: Exhibition
type: exhibition
description: 'A temporary or permanent exhibition with a run of dates, a hero image and topics.'
help: ''
new_revision: true
preview_mode: 1
display_submitted: false

The keys map one to one onto the form: type is the machine name, name the label, new_revision the revision default, preview_mode is 0 (disabled), 1 (optional) or 2 (required), and display_submitted the author line. The default published and promoted state is not here: it is stored in a base_field_override file (see Base fields versus configurable fields).

Deleting a content type is allowed only while no content of that type exists; the User Guide’s section 6.2, "Deleting a Content Type", describes the screen. Deleting a type also deletes its field instances and display configuration, and purges the field storages that no other bundle uses (see What happens when a field is deleted).

Adding fields in the Field UI

Open Manage fields of the content type (/admin/structure/types/manage/exhibition/fields). Field UI is the Field UI module, enabled in the Standard profile and in Drupal CMS, and normally disabled on production sites (see Security and pitfalls). Since Drupal 10.2 the Create a new field button opens a redesigned dialog with a grid of field type groups (General, Text, Number, Reference, Selection, Date and time, Other) and a second step where you choose the specific type or widget. Older tutorials and the Drupal 10 books show the previous single-page "Add field" form with a drop-down list of types and a separate "Re-use an existing field" drop-down; the current UI still offers both, only the layout changed, and the choices behind it are the same.

For each new field the UI asks two kinds of questions in two stages: the storage settings (field type, machine name, cardinality, and the type-specific storage options) and the instance settings (label, help text, required, default value and the type-specific options for this bundle). The next sections explain the two stages.

Field storage versus field instance

This distinction is the most important idea of the Field system, and it is the one the UI hides best.

Field storage Field instance (field config)

Config entity class

Drupal\field\Entity\FieldStorageConfig

Drupal\field\Entity\FieldConfig

Config name

field.storage.<entity_type>.<field_name>, for example field.storage.node.field_dates

field.field.<entity_type>.<bundle>.<field_name>, for example field.field.node.exhibition.field_dates

Scope

One per entity type and field name; shared by every bundle that uses the field

One per bundle; defines how the shared field behaves in that bundle

Decides

Field type, cardinality, whether it is translatable, storage settings such as the maximum length of a string or the target type of a reference, and indirectly the database tables and columns

Label, help text (the description), required, default value, bundle-specific settings such as the allowed target bundles of a reference or the allowed file extensions of a file field, and the per-bundle translatable flag

Can change after content exists?

Mostly no: the type, entity type and field name are immutable, and settings that change the schema are locked

Yes; labels, help text, required and defaults are free to change

The practical effect is that one storage can serve several bundles. If you create field_dates on exhibition and later open Manage fields of event, you can Re-use an existing field and pick field_dates from the list: Drupal creates only a new field instance (field.field.node.event.field_dates), not a second storage and not a second table. The two bundles then share the table node__field_dates and the same cardinality, but each keeps its own label, help text, default value and required flag. The sharing has two consequences you should plan for:

  • a storage setting that matters to one bundle (for example, the unlimited cardinality of field_topics) applies to every bundle that re-uses the field; and

  • views and entity queries can filter on a re-used field across bundles in one query, which is why it is worth re-using a field such as field_dates when the meaning is really the same, and creating a separate field when it is not.

# Show the shared storage and both instances of the date field.
ddev drush config:get field.storage.node.field_dates
ddev drush config:get field.field.node.exhibition.field_dates
ddev drush config:get field.field.node.event.field_dates

The field machine name

The machine name is chosen once. The UI prefixes it with field_ (the prefix is not required by the API, but is a convention of the UI and of every contrib module; configurable fields created by code can have any name that is not reserved). A machine name is limited to 32 characters including the prefix, so the name part has at most 25 characters. The name becomes the database table suffix (node__field_dates), the column prefix (field_dates_value), the name of the PHP property ($node->field_dates), the key in Twig (content.field_dates) and the key in JSON:API attributes. Plan names such as field_dates rather than field_exhibition_run_dates_2026: the name cannot change and a very long name makes the generated table names exceed the database identifier limit, in which case Drupal shortens them with a hash.

Choosing the field type

A field type defines what is stored and its columns. Drupal core ships many types, grouped by the module that provides them.

Field type (plugin ID) Provided by Meridian example

Text (formatted, long) text_long, Text (formatted, long, with summary) text_with_summary, Text (formatted) text

Text

The exhibition body with a summary used as the teaser

Text (plain) string, Text (plain, long) string_long

Core

field_subtitle (plain, up to 160 characters)

Number: Integer integer, Decimal decimal, Float float

Core

field_capacity (integer, at least 1)

Boolean boolean

Core

field_members_only on events

Date datetime, Date range daterange, Timestamp timestamp

Datetime, Datetime Range, Core

field_dates (date range, date only)

Link link, Email email, Telephone telephone

Link, Core, Telephone

field_ticket_url (external link)

Image image, File file

Image, File

Used inside media types; see Media and the Media Library

List (text, integer, float) list_string, list_integer, list_float

Options

field_audience with the allowed values all, families, adults

Reference: Content, User, Taxonomy term, Media, Other entity_reference

Core

field_topics, field_gallery, field_hero, field_exhibition

The full list, with the widgets and formatters each type offers, and the way to write your own, are in Field types and entity references and Field type, widget and formatter plugins. Modules the Standard profile no longer enables can remove types from the list: the Comment type is available only with the Comment module, Telephone only with Telephone (which Drupal 12 moves to contrib; see Preparing for Drupal 12).

Cardinality

Allowed number of values is a storage setting and is stored as cardinality: a positive integer, or -1, the value of the constant FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, for unlimited. In the UI you choose Limited and a number, or Unlimited. Cardinality matters in three ways.

  • It sets the widget: a single-value text field is one input; a multi-value field repeats the input, with Add another item and drag handles, or, for entity references with the autocomplete tags style and checkboxes, a single control.

  • It sets the shape of the data: a single-value field yields $node->field_dates->value; a multi-value field is a list that you iterate with foreach ($node->field_topics as $item) and that you read as an array with getValue().

  • It sets the storage: all values, of any cardinality, go to the same table, one row per delta; there is no extra table for multi-value fields.

You can raise the cardinality at any time. Lowering it on a field that already holds more values than the new limit leaves the surplus values in the table but no longer editable or validated, so reduce it only after checking the data with a query. Changing between "limited to 1" and "unlimited" is therefore safe in one direction (more values) and risky in the other. Be careful with 1 versus -1 in the JSON:API and Twig output as well: a single-value reference serialises as an object, a multi-value one as an array, so changing it later also changes API consumers.

Required, default value, help text and label

These are instance settings, defined per bundle and stored in field.field.*.

Setting Meaning and behaviour YAML key

Label

What editors see above the widget and what the default formatter prints as the field label. Can differ between bundles.

label

Help text

Shown below the widget. It is the field instance’s description; the CLI option is --field-description. Use it for guidance editors need at entry time (format, length, what happens on publication), not for decorative text.

description

Required field

The node form refuses to save without a value. Required applies only to the form and to validation constraints; it does not add a database constraint, so code that saves entities with save() is also validated by validate() but data inserted directly into the table is not.

required

Default value

Pre-fills new entities, using the widget in the form: a static value (for example families in field_audience) or a PHP callback (default_value_callback, a static method returning an array of items). Default values apply when an entity is created, not to existing entities.

default_value, default_value_callback

Translatable (when Content Translation is enabled)

Whether values are translated per language or shared across translations; see Multilingual.

translatable

The exhibition instance of the date field therefore looks as follows.

# config/sync/field.field.node.exhibition.field_dates.yml
langcode: en
status: true
dependencies:
  config:
    - field.storage.node.field_dates
    - node.type.exhibition
  module:
    - datetime_range
id: node.exhibition.field_dates
field_name: field_dates
entity_type: node
bundle: exhibition
label: 'Run dates'
description: 'First and last day of the exhibition. Leave the end date empty for a permanent exhibition.'
required: true
translatable: false
default_value: {  }
default_value_callback: ''
settings: {  }
field_type: daterange

and the shared storage it depends on:

# config/sync/field.storage.node.field_dates.yml
langcode: en
status: true
dependencies:
  module:
    - datetime_range
    - node
id: node.field_dates
field_name: field_dates
entity_type: node
type: daterange
settings:
  datetime_type: date
module: datetime_range
locked: false
cardinality: 1
translatable: true
indexes: {  }
persist_with_no_fields: false
custom_storage: false

Read the two files side by side with the Field UI. The config: dependencies of the instance name the storage and the bundle: exporting and importing a configuration set therefore creates the bundle and storage first, and deleting the storage removes every instance. The storage’s settings (here datetime_type: date, date without a time of day) are the type-specific storage settings; the instance’s own settings (empty here) are type-specific instance settings. For entity_reference the target type (target_type: taxonomy_term) is a storage setting and the allowed target bundles and their sort order (handler: 'default:taxonomy_term', handler_settings.target_bundles) are instance settings.

The keys locked, persist_with_no_fields and custom_storage are rarely used: locked forbids deleting the field in the UI (modules set it for fields they depend on), persist_with_no_fields keeps the storage when the last instance is removed, and custom_storage tells Drupal that a module stores the values itself, so no table is created.

Field storage settings that become locked

Once an entity has data in a field, Drupal blocks storage changes that could lose or corrupt it. The UI then shows the storage settings as read-only with the message that "there is data for this field in the database". The type, the entity type and the machine name are always immutable. For example, the maximum length of a plain text field can be increased but not lowered, the target type of an entity reference cannot be changed, and an allowed-values list can have values added but not removed while content uses them. The safe path for a real change is usually to create a new field, migrate the data with an update hook or a migration, switch displays and queries and delete the old field. See Update API and post updates for the hook that performs the data move.

Building the full exhibition and event models

The following fields make the Meridian model concrete. The Storage column is what is shared; the Instance column is per bundle.

Bundle Field (machine name) Storage Instance

exhibition

Run dates field_dates

daterange, date only, cardinality 1

Required; help text about permanent exhibitions

exhibition

Subtitle field_subtitle

string, 160 characters, cardinality 1

Optional

exhibition

Hero media field_hero

entity_reference to media, cardinality 1

Required; allowed bundles: image

exhibition

Topics field_topics

entity_reference to taxonomy_term, cardinality unlimited

Allowed vocabulary: topic; create referenced terms on the fly

exhibition

Gallery field_gallery

entity_reference to taxonomy_term, cardinality 1

Allowed vocabulary: gallery

event

Dates field_dates (re-used)

Shared with exhibition

Required; own label "Event dates"

event

Ticket link field_ticket_url

link, cardinality 1

External links only, link title disabled

event

Capacity field_capacity

integer, cardinality 1

Minimum 1, suffix " seats"

event

Related exhibition field_exhibition

entity_reference to node, cardinality 1

Allowed bundle: exhibition

The taxonomy and media side of this model are covered in Taxonomy and Media and the Media Library; here they are only reference targets.

A link field illustrates that instance settings carry most of the editorial behaviour:

# config/sync/field.storage.node.field_ticket_url.yml
langcode: en
status: true
dependencies:
  module:
    - link
    - node
id: node.field_ticket_url
field_name: field_ticket_url
entity_type: node
type: link
settings: {  }
module: link
locked: false
cardinality: 1
translatable: true
indexes: {  }
persist_with_no_fields: false
custom_storage: false
# config/sync/field.field.node.event.field_ticket_url.yml
langcode: en
status: true
dependencies:
  config:
    - field.storage.node.field_ticket_url
    - node.type.event
  module:
    - link
id: node.event.field_ticket_url
field_name: field_ticket_url
entity_type: node
bundle: event
label: 'Ticket link'
description: 'Link to the ticket shop. Leave empty for free events.'
required: false
translatable: false
default_value: {  }
default_value_callback: ''
settings:
  title: 0
  link_type: 16
field_type: link

In the link instance link_type: 16 means external links only (LinkItemInterface::LINK_EXTERNAL; 1 is internal only and 17 is both) and title: 0 disables the link text. The UI hides these numbers behind radio buttons, but you will meet them in the exported YAML.

Manage form display: widgets

Manage form display (/admin/structure/types/manage/exhibition/form-display) decides how each field appears on the create and edit forms. The User Guide describes it in "6.8. Concept: Forms and Widgets" and "6.9. Changing Content Entry Forms". For each field you can:

  • choose a widget from those the field type supports (for a daterange field: Date and time range, Date range with all-day option, or Date range with HTML5 inputs; for entity_reference: autocomplete, autocomplete tags style, select list, check boxes or radio buttons; for text_long: the CKEditor 5 formatted text area, or a plain text area);

  • change the widget’s own settings (the rows of a text area, the placeholder, the size of an input, the number of matches of an autocomplete, whether the autocomplete may create missing terms);

  • change the weight by dragging the field, which orders the form;

  • move a field to Disabled, which hides it from the form (and, when the field is required, makes saving impossible);

  • optionally group the fields in sub-forms, with the contrib Field Group module (not core).

The Disabled region does not delete data: it only stops the widget from being shown. A field that is both required and disabled on the default form is a typical cause of the "content cannot be saved" report for a type.

Form modes

The default form is core.entity_form_display.node.exhibition.default. Each entity type can have additional form modes (core.entity_form_mode.node.<name>), enabled per bundle under Custom display settings at the bottom of the Manage form display page; core defines a register form mode for users, so that the registration form can show fewer fields than the account edit form. The User Guide notes that this is rarely used for content. A form mode is a separate entity_form_display config entity per bundle and mode, for example core.entity_form_display.user.user.register.

The form display configuration

# config/sync/core.entity_form_display.node.exhibition.default.yml
langcode: en
status: true
dependencies:
  config:
    - field.field.node.exhibition.body
    - field.field.node.exhibition.field_dates
    - field.field.node.exhibition.field_hero
    - field.field.node.exhibition.field_subtitle
    - field.field.node.exhibition.field_topics
    - node.type.exhibition
  module:
    - datetime_range
    - media_library
    - path
    - text
id: node.exhibition.default
targetEntityType: node
bundle: exhibition
mode: default
content:
  body:
    type: text_textarea_with_summary
    weight: 4
    region: content
    settings:
      rows: 9
      summary_rows: 3
      placeholder: ''
      show_summary: true
    third_party_settings: {  }
  field_dates:
    type: daterange_default
    weight: 3
    region: content
    settings: {  }
    third_party_settings: {  }
  field_hero:
    type: media_library_widget
    weight: 2
    region: content
    settings:
      media_types: {  }
    third_party_settings: {  }
  field_subtitle:
    type: string_textfield
    weight: 1
    region: content
    settings:
      size: 60
      placeholder: ''
    third_party_settings: {  }
  field_topics:
    type: entity_reference_autocomplete_tags
    weight: 5
    region: content
    settings:
      match_operator: CONTAINS
      match_limit: 10
      size: 60
      placeholder: ''
    third_party_settings: {  }
  path:
    type: path
    weight: 30
    region: content
    settings: {  }
    third_party_settings: {  }
  status:
    type: boolean_checkbox
    weight: 120
    region: content
    settings:
      display_label: true
    third_party_settings: {  }
  title:
    type: string_textfield
    weight: 0
    region: content
    settings:
      size: 60
      placeholder: ''
    third_party_settings: {  }
hidden:
  promote: true
  sticky: true
  uid: true
  created: true

Notice that base fields (title, status, path) are configured in the same file as configurable fields, and that anything not listed under content and not under hidden is simply not part of the form. Notice also that a field that exists but has no entry in this file does not appear on the form: this is why a field added by a recipe or an import can seem to "disappear" until a form display entry is added.

Manage display: formatters and view modes

Manage display (/admin/structure/types/manage/exhibition/display) controls how fields are rendered when an entity is viewed. The User Guide covers it in "6.10. Concept: View Modes and Formatters" and "6.11. Changing Content Display". A view mode is a context in which an entity is shown (full page, teaser, search result, RSS); each has its own core.entity_view_display.node.exhibition.<mode> configuration. For each field in a view mode you decide:

  • whether it is shown, and its weight and region (Content or Disabled);

  • the label position: Above, Inline, Hidden or Visually hidden;

  • the formatter (a date range can be shown with a Default formatter, a Plain ISO formatter or a Custom PHP date format; a media reference as a rendered entity in a view mode, as a label or as a link; text as default, trimmed or summary or trimmed);

  • the formatter’s own settings (date format, separator, trim length, link target, whether the entity reference label links to the entity).

At the bottom of the page, Custom display settings lists the view modes the bundle can use. A mode that is not enabled for the bundle falls back to the default display: this explains why a newly created teaser mode looks like the full page until you enable it and configure it. Display configuration, view modes, responsive images and image styles are the subject of Display modes and image styles; the Twig side is in Template suggestions and preprocess.

# config/sync/core.entity_view_display.node.exhibition.default.yml (excerpt)
langcode: en
status: true
dependencies:
  config:
    - field.field.node.exhibition.field_dates
    - node.type.exhibition
  module:
    - datetime_range
    - user
id: node.exhibition.default
targetEntityType: node
bundle: exhibition
mode: default
content:
  field_dates:
    type: daterange_default
    label: above
    settings:
      timezone_override: ''
      format_type: long
      separator: '-'
    third_party_settings: {  }
    weight: 1
    region: content
  links:
    settings: {  }
    third_party_settings: {  }
    weight: 100
    region: content
hidden:
  langcode: true

The form display and the view display are independent. A field can be editable and not shown, or shown and not editable. Neither one defines the data: data and validation belong to the storage and the instance. This is also why headless consumers such as JSON:API are not affected by the display configuration: they expose the fields that exist and are accessible, not the fields a theme renders.

Base fields versus configurable fields

Every field is either a base field or a configurable field. The difference is where it is defined and where it is stored.

Base field Configurable field

Defined by

PHP code: baseFieldDefinitions() of the entity class, or hook_entity_base_field_info() from a module

A config entity pair: field.storage.* and field.field.*, created in the Field UI, by Drush, by a recipe or by code

Exists on

Every bundle of the entity type

Only the bundles that have an instance

Exported in configuration

No, except for per-bundle customisations (base_field_override.*)

Yes

Stored in

The entity type’s shared tables (node, node_field_data, node_revision, node_field_revision)

One dedicated table per field (node__field_*) and one revision table (node_revision__field_*)

Example

title, status, uid, created, changed, promote, sticky on node

body, field_dates, field_topics

A site builder cannot add or remove base fields in the UI. The node entity type defines them in Drupal\node\Entity\Node::baseFieldDefinitions(), and modules add more (the Content Moderation module adds moderation_state, Path adds path, Content Translation adds metadata fields). What you can change in the UI is a subset of their instance-like settings. When you set the title label to "Event name", or change the default of Promoted to front page in the Add content type form, Drupal stores a base field override, a config entity of type BaseFieldOverride that applies only to one bundle:

# config/sync/core.base_field_override.node.event.title.yml
langcode: en
status: true
dependencies:
  config:
    - node.type.event
id: node.event.title
field_name: title
entity_type: node
bundle: event
label: 'Event name'
description: ''
required: true
translatable: true
default_value: {  }
default_value_callback: ''
settings: {  }
field_type: string
# config/sync/core.base_field_override.node.event.promote.yml (excerpt)
id: node.event.promote
field_name: promote
entity_type: node
bundle: event
label: 'Promoted to front page'
required: false
default_value:
  -
    value: 0
field_type: boolean

An override can change the label, description, required flag and default value, but it cannot change the field type, the cardinality or the storage. Anything that needs different storage must be a configurable field or a code change.

Defining base fields in code

A custom content entity declares its own base fields, which is the usual way to store a fixed, central set of values. The collection_object entity of Meridian Museum defines accession_number that way (the full entity is built in Custom entity types):

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

/**
 * Excerpt: the base fields of the collection object entity.
 */
abstract class CollectionObjectBase extends ContentEntityBase {

  public static function baseFieldDefinitions(EntityTypeInterface $entity_type): array {
    $fields = parent::baseFieldDefinitions($entity_type);

    $fields['accession_number'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Accession number'))
      ->setDescription(t('The museum inventory number, for example 1987.42.3.'))
      ->setRequired(TRUE)
      ->setSetting('max_length', 32)
      ->addConstraint('UniqueField')
      ->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => 0])
      ->setDisplayOptions('view', ['label' => 'inline', 'type' => 'string', 'weight' => 0])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    $fields['on_display'] = BaseFieldDefinition::create('boolean')
      ->setLabel(t('On display'))
      ->setDefaultValue(FALSE)
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    return $fields;
  }

}

The calls setDisplayOptions() give the default widget and formatter that the entity ships with, and setDisplayConfigurable() lets a site builder still change them in the Field UI: without it the display of a base field is fixed in code. Cardinality is set with ->setCardinality(-1) (or the constant). Constraints such as UniqueField are added with addConstraint(). After adding or changing a base field definition in code you must apply the schema change through an update (hook_update_N() using EntityDefinitionUpdateManager or a post update); on the Status report a pending change appears as "Mismatched entity and/or field definitions", an error that older books also describe with the Drush entity:updates command, removed in Drush 9. Do not run a schema change on a live site without a database backup; see Update API and post updates.

Adding a bundle field from code

A module can add a field to a bundle, without creating configuration, with hook_entity_bundle_field_info(). Since Drupal 11.1 hooks are implemented as attribute methods on a class in src/Hook (older Drupal 10 modules and books write a procedural function in the .module file), as shown here for the Meridian module:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Entity field hooks of the Meridian collection module.
 */
final class FieldHooks {

  use StringTranslationTrait;

  /**
   * Adds a computed read-only "days remaining" field to exhibitions.
   */
  #[Hook('entity_bundle_field_info')]
  public function bundleFieldInfo(EntityTypeInterface $entity_type, string $bundle, array $base_field_definitions): array {
    $fields = [];
    if ($entity_type->id() === 'node' && $bundle === 'exhibition') {
      $fields['days_remaining'] = BaseFieldDefinition::create('integer')
        ->setName('days_remaining')
        ->setLabel($this->t('Days remaining'))
        ->setComputed(TRUE)
        ->setClass('\Drupal\meridian_collection\Plugin\Field\DaysRemainingItemList')
        ->setReadOnly(TRUE)
        ->setDisplayConfigurable('view', TRUE);
    }
    return $fields;
  }

}

Fields defined this way are code-defined, appear in Manage display but are not exported as field.storage.*, and are suited to computed values. For a stored value, create a configurable field instead: it is simpler, exportable and translatable. Hooks and the OOP hook system are covered in Hooks and OOP hooks.

What is stored where

Drupal’s default SQL storage (SqlContentEntityStorage) puts the data of an entity type in a set of tables whose names follow the entity type and field names. For a node with the revisions and translations of Drupal 11 these are:

Table Content

node

One row per node: the identifiers (nid, vid, type, uuid) and the original langcode. The type column holds the bundle (exhibition, event).

node_field_data

One row per node and translation: the translatable and revisionable base fields (title, status, uid, created, changed, promote, sticky, langcode, default_langcode) for the current revision.

node_revision

One row per revision: vid, nid, langcode, and the revision metadata (revision_timestamp, revision_uid, revision_log, revision_default).

node_field_revision

One row per revision and translation of the base fields.

node__field_dates

One table per configurable field: the values of the current revision. Columns entity_id, revision_id, bundle, delta, langcode, deleted and one column per property of the type, here field_dates_value and field_dates_end_value.

node_revision__field_dates

The same columns for every revision.

node__body

The body field is an ordinary configurable field, with columns body_value, body_summary and body_format.

Two rules explain almost every question about the schema:

  • Base fields live in shared tables; configurable fields live in their own tables. A node’s title is a column of node_field_data; its dates are rows of node__field_dates.

  • The column names are the field name plus the property name. A link field field_ticket_url has field_ticket_url_uri, field_ticket_url_title and field_ticket_url_options; an entity reference field_topics has field_topics_target_id; an image has _target_id, _alt, _title, _width and _height.

A multi-value field has one row per value, ordered by delta; there is no serialised array. The primary key of the data table is (entity_id, deleted, delta, langcode), with indexes on bundle and revision_id. A date stored by the datetime types is a string in UTC (2026-11-14 for a date only, 2026-11-14T18:30:00 for date and time), not a database date type, and a timestamp field is an integer.

# What do the Meridian exhibitions look like in the database?
ddev drush sql:query "SELECT n.nid, d.title, f.field_dates_value, f.field_dates_end_value
  FROM node n
  JOIN node_field_data d ON d.nid = n.nid AND d.vid = n.vid
  LEFT JOIN node__field_dates f ON f.entity_id = n.nid AND f.revision_id = n.vid
  WHERE n.type = 'exhibition' ORDER BY f.field_dates_value DESC LIMIT 5"

This query is read-only and is a way to understand the layout, not a way to read content in a module. Application code should use the entity API and entity queries, which respect translations, revisions, access rules and cache invalidation; writing directly to node__* tables skips all of them and leaves caches and search indexes stale.

Why the tables are laid out this way

A dedicated table per configurable field is what lets a site builder add a field without altering a shared table: adding field_capacity creates node__field_capacity (and the revision table) and nothing else. It also makes multi-value fields natural and lets Views join only the fields it needs. The cost is more joins: a node with twenty fields needs a join per field, which is one of the reasons the page cache, the render cache and Views caching matter (see Performance and caching) and why a content model with several hundred fields per bundle is an anti-pattern. Entities with custom_storage: true fields, or entity types using a non-SQL storage, behave differently, and Drupal itself can use a shared table layout for base fields only. Wide content models are better split across several entities with references than put in one very wide node.

Translations and revisions

Both translation and revision support are properties of the entity type (the node declares both) and of each field storage. For translatable fields, one row per language is stored; for non-translatable fields, the single value is shared by every translation. Revisions copy field values into the *_revision tables each time a new revision is saved; this is why the Create new revision default matters for the table size, and why moderation workflows (see Content moderation, workflows and revisions) create many rows.

What happens when a field is deleted

When you delete a field instance in Manage fields, Drupal marks the instance as deleted and the values stay in the table until cron purges them, in batches, with field_purge_batch(); the deleted column of the data table is the marker. If the field storage has no instances left, it is deleted too, and its tables are dropped only when the last value has been purged. So a deleted field can still occupy space and appear in the Status report until cron runs. On a site with very many values you can run drush cron repeatedly, or raise the purge batch size with the field_purge_batch_size setting (documented in default.settings.php). Deleting a field whose configuration is still in the exported sync directory without removing it there as well will bring it back on the next config:import, so delete in one environment and export the change, instead of deleting in every environment.

From UI choices to configuration YAML

Putting the pieces together, the following table shows what each action in the Field UI creates or changes in the active configuration. Run drush config:export and use git diff config/sync to see it after any change.

UI action Configuration affected

Add content type

Creates node.type.<type>, one core.base_field_override.node.<type>.* per base field whose default or label changed, the default core.entity_form_display.node.<type>.default and core.entity_view_display.node.<type>.default and updates the language.content_settings.node.<type> file when Content Translation is enabled

Add field (new storage)

Creates field.storage.node.<field> and field.field.node.<type>.<field>, and adds the field to the form display and the view display of the bundle (with the type’s default widget and formatter)

Re-use an existing field

Creates only field.field.node.<type>.<field> and the display entries; the storage is unchanged

Change label, help text, required, default value

Edits field.field.node.<type>.<field> only

Change cardinality or a storage setting

Edits field.storage.node.<field> (and therefore affects every bundle that re-uses the field)

Change widget or widget settings

Edits core.entity_form_display.node.<type>.<mode>

Change formatter, label position or order

Edits core.entity_view_display.node.<type>.<mode>

Enable a custom view mode for the bundle

Adds an core.entity_view_display.node.<type>.<mode> file with status: true

Delete field

Removes field.field.node.<type>.<field>, its display entries and, if it was the last instance, the storage

Dependencies are explicit in each file (dependencies.config), and config:import creates things in the right order: bundle, storage, instance, displays. The consequences are practical.

  • Always commit the storage, the instances and the displays together. An instance imported without its storage fails with a "missing dependency" error, and one imported without a display entry will not show on the form.

  • Never edit these files by hand to change a type or a storage setting on a site with data. Use an update hook or a migration; the active configuration and the database schema can diverge.

  • Machine names are API. Renaming field_dates in YAML does not rename the table or move the data.

Configuration management and Configuration API and schema explain how these files are exported, validated against their schema and imported, and why each of the config types above has a schema (field.storage.*, field.field.*, core.entity_form_display.*), which is what catches wrong YAML before it reaches the database.

Doing the same without the UI

Drush can create the same fields, which is useful in scripts, in tests and in a project README.

# Create the exhibition date range non-interactively (Drush 13/14).
ddev drush field:create node exhibition \
  --field-name=field_dates \
  --field-label="Run dates" \
  --field-description="First and last day of the exhibition." \
  --field-type=daterange \
  --field-widget=daterange_default \
  --is-required=1 \
  --cardinality=1

# Re-use it on the event bundle.
ddev drush field:create node event --existing-field-name=field_dates

# Export the result.
ddev drush config:export --yes

The field:create command (alias fc) has existed since Drush 11 and prompts for anything not given as an option; related commands field:info, field:delete, field:clone and field:base-info help you inspect and change a model. Use drush field:create to set up a model, but still export and commit the resulting configuration: the command changes only the active configuration of the site it runs on.

Creating fields programmatically

An update hook or a recipe-like script creates fields with the same config entities the UI uses. Add the storage first, then the instance, then the display entries:

<?php

declare(strict_types=1);

use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;

/**
 * Adds the "audience" list field to events.
 */
function meridian_collection_post_update_event_audience(): void {
  $field_name = 'field_audience';

  if (!FieldStorageConfig::loadByName('node', $field_name)) {
    FieldStorageConfig::create([
      'field_name' => $field_name,
      'entity_type' => 'node',
      'type' => 'list_string',
      'cardinality' => 1,
      'settings' => [
        'allowed_values' => [
          'all' => 'Everyone',
          'families' => 'Families',
          'adults' => 'Adults',
        ],
      ],
    ])->save();
  }

  if (!FieldConfig::loadByName('node', 'event', $field_name)) {
    FieldConfig::create([
      'field_name' => $field_name,
      'entity_type' => 'node',
      'bundle' => 'event',
      'label' => 'Audience',
      'description' => 'Who the event is mainly for.',
      'required' => TRUE,
      'default_value' => [['value' => 'all']],
    ])->save();
  }

  EntityFormDisplay::load('node.event.default')
    ?->setComponent($field_name, ['type' => 'options_select', 'weight' => 6])
    ->save();
  EntityViewDisplay::load('node.event.default')
    ?->setComponent($field_name, ['type' => 'list_default', 'label' => 'inline', 'weight' => 6])
    ->save();
}

Guard with loadByName() so that the script can run again, and keep in mind that the configuration the script creates must also be exported, or the next config:import will delete the field (it is not in the sync directory). In a recipe, the same model is expressed as exported YAML files in the recipe’s config/ directory plus a recipe.yml that lists its module dependencies; the meridian_events recipe ships exactly the event files shown on this page, and a recipe cannot update a field that already exists with different settings, so it is meant for fresh sites.

Reading and writing field values

The entity API hides the table layout. Field items are objects with properties; the "main property" gives the common value.

<?php

declare(strict_types=1);

use Drupal\node\Entity\Node;

$exhibition = Node::create([
  'type' => 'exhibition',
  'title' => 'Light and Sound',
  'status' => 1,
  'field_subtitle' => 'An exhibition about waves',
  'field_dates' => [
    'value' => '2026-11-14',
    'end_value' => '2027-03-28',
  ],
  'field_topics' => [
    ['target_id' => 12],
    ['target_id' => 15],
  ],
]);
$exhibition->save();

// Single value: main property, or a named property.
$start = $exhibition->get('field_dates')->value;
$end = $exhibition->get('field_dates')->end_value;

// Multi value: iterate the items, or fetch the referenced entities.
foreach ($exhibition->get('field_topics') as $item) {
  $term_id = $item->target_id;
}
$terms = $exhibition->get('field_topics')->referencedEntities();

// Append a value, check emptiness, validate before saving.
$exhibition->get('field_topics')->appendItem(['target_id' => 18]);
$is_empty = $exhibition->get('field_subtitle')->isEmpty();
$violations = $exhibition->validate();

An entity query searches by field and bundle, and must state whether to check access.

<?php

declare(strict_types=1);

$nids = \Drupal::entityTypeManager()->getStorage('node')->getQuery()
  ->accessCheck(TRUE)
  ->condition('type', 'exhibition')
  ->condition('status', 1)
  ->condition('field_dates.end_value', date('Y-m-d'), '>=')
  ->sort('field_dates.value', 'ASC')
  ->range(0, 10)
  ->execute();

The \Drupal:: static call is acceptable in a script, but in a service or controller inject EntityTypeManagerInterface, as explained in Services and dependency injection. A query without accessCheck() throws an exception in current Drupal versions; the older books that omit it describe behaviour of Drupal 8 and 9, where it only emitted a deprecation. In Drupal 12 query objects are also no longer serialisable, so do not store them in the cache or in a queue item.

Content types in the rest of the system

A bundle is also the unit that other subsystems attach to. The following list points to the pages where each is covered.

  • Permissions. Each content type has its own permissions (Exhibition: Create new content, Edit own content, Edit any content, Delete own content, Delete any content, View revision history…​), which the Meridian Content Editor, Curator and Reviewer roles are granted differently; see Users, roles and permissions.

  • Moderation. A content moderation workflow (draft, review, published) is applied to chosen bundles; see Content moderation, workflows and revisions.

  • Paths. A Pathauto pattern (contrib) or the core URL alias field is set per bundle; see Menus and path aliases.

  • Translation. Which bundles and fields are translatable is stored in language.content_settings.node.exhibition and on each field; see Multilingual.

  • Views and APIs. A view can filter on the bundle and show any field; JSON:API exposes node--exhibition as a resource type with the fields as attributes and relationships; see Views and JSON:API.

  • Layout. Layout Builder can override the view display of a bundle per entity; see Layout Builder. Drupal CMS 2.x uses Canvas as the default editor and does not change the content model: fields still come from the bundle (see Drupal CMS and site templates).

Developers also meet bundles in code: $entity->bundle() returns the bundle ID; $node->getType() is the node-specific equivalent; and EntityTypeBundleInfoInterface::getBundleInfo('node') lists all bundles of an entity type, as used by route access and by the Add content page. See Entity API.

Security and pitfalls

Field UI belongs on development, not on production. The Field UI module lets anyone with the Administer content types and field permissions change the structure of the site; on a site that deploys configuration it should be uninstalled in production, and the corresponding permissions are not given to editors (they are marked "restrict access" for that reason). A structure change made directly on production is lost at the next config:import, or worse, creates a tables-versus-configuration mismatch.

Do not rename machine names, and do not "fix" them by hand. Renaming a field or a bundle is not supported: the tables, display files, views and API consumers all use the name. Recreate the field and migrate the data.

A required field is not a data guarantee. It is checked by the form and by validate(). Imports, migrations and code that write with save() without calling validate() can store empty required fields. Add a validation constraint if a rule must always hold.

Reducing cardinality or changing a storage type risks data. Add a new field and migrate the values instead.

Re-using a field shares the storage. Cardinality and storage settings apply to every bundle. Re-use only where the meaning is identical.

Hidden is not secret. Disabling a field on the form display or the view display does not restrict access to its values: JSON:API and REST expose every field the user may access. Field-level access is controlled with permissions (View/Edit field_x when the module adds them) or hook_entity_field_access(); see Access control and permissions in code.

Render formatted text safely. Text fields store a format with the value; always render them with their format (the default formatter does). Printing {{ node.field_x.value|raw }} in Twig bypasses the text format filtering and is an XSS risk; see Text formats and CKEditor 5.

A deleted type leaves nothing behind except what you forgot. Remove the content first; references from views, menus and recipes to the deleted type must also go.

Mind the field and bundle count. Each new field is a table; each bundle is a set of display entities. Prefer fewer, re-usable fields with clear meaning over hundreds of near-duplicates.

Version notes and corrections of older material

Topic What changed and what older material says

Standard profile content types

Drupal 11.4 stopped creating the Article and Page types, so a new Standard install has no content types. Drupal 10 Masterclass (2023) and other material written for Drupal 10 assume the pre-installed types; create your own first, or apply a recipe or Drupal CMS.

Add field dialog

Drupal 10.2 introduced the redesigned field creation dialog described above. Drupal 10 Development Cookbook (2023) and Drupal 10 Masterclass show the earlier two-step form with a type drop-down; the concepts are unchanged.

Annotations versus attributes

Content entity types, field types, widgets and formatters are declared with PHP attributes (#[ContentEntityType], #[FieldType], #[FieldWidget], #[FieldFormatter]) since Drupal 10.2. Annotations still work on the deprecation path, but new code, and this page’s sub-section, use attributes. The Drupal 10 Development Cookbook shows annotations.

Hooks

hook_entity_base_field_info() and hook_entity_bundle_field_info() can be implemented as #[Hook] methods since 11.1. The books show procedural .module functions, which still work.

entity:updates Drush command

Removed in Drush 9. Use update hooks or post updates for entity and field definition changes.

Entity query access

accessCheck() is mandatory. Older code and books that omit it fail on current versions.

Drush

drush field:create is available from Drush 11 and works with Drush 13/14 (the Drush versions for Drupal 11.4); Drush 12 and earlier cookbook examples use other command forms.

Drupal 12

The field system, Field UI and configuration format described here are unchanged by the announced removals. Drupal 12 moves the Field Layout and Telephone modules to contrib, so a model that uses them must install the contrib versions before upgrading; see Preparing for Drupal 12.

For the complete list of outdated advice see Where older books and tutorials are outdated.

Summary

  • A content entity belongs to one bundle of one entity type; the bundle decides its configurable fields. The content type is the bundle of nodes.

  • A configurable field has a storage (type, cardinality, columns, table), shared by every bundle that re-uses it, and one instance per bundle (label, help text, required, default value, bundle settings).

  • The form display chooses widgets and the view display chooses formatters; they are separate configuration per bundle and mode.

  • Base fields are defined in code, live in shared tables and are customised per bundle with base field overrides; configurable fields have their own tables (node__field_*) and are exported as field.storage.* and field.field.*.

  • Every UI choice becomes a YAML file; commit storage, instance and displays together, and change models on development, not on production.

  • Plan machine names, cardinality and field re-use before content exists: storage changes on a site with data need an update hook or a migration.