DEV Community

Hugo do Carmo
Hugo do Carmo

Posted on • Edited on

When Large Text Belongs in a Separate Table

The Hidden Cost of "Just One More Column": When Large Text Belongs in a Separate Table

This is Part I of a series about vertical partitioning and one-to-one table design.

Picture a simple books table. It contains the fields you would expect: title, author, ISBN, publication year, and a summary column with a few paragraphs about the book.

CREATE TABLE books (
    id               BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title            VARCHAR(255) NOT NULL,
    author           VARCHAR(255) NOT NULL,
    isbn             VARCHAR(20) NOT NULL,
    published_year   SMALLINT UNSIGNED NOT NULL,
    summary          TEXT NOT NULL,
    created_at       TIMESTAMP NULL DEFAULT NULL,
    updated_at       TIMESTAMP NULL DEFAULT NULL
);
Enter fullscreen mode Exit fullscreen mode

There is nothing inherently wrong with this design. The summary belongs to the book, the table is easy to understand, and keeping the data together may be the best choice for many applications.

A large text field does not automatically need its own table.

The problem appears when the application's access pattern changes. The summary may be required on a book details page, but not in search results, dropdowns, reports, or administrative listings. If the data-access technology loads every mapped column by default, a reasonable schema can begin producing unnecessary work.

The query that quietly becomes expensive

Imagine a page that lists every book published after 2010. It only needs three fields:

SELECT id, title, published_year
FROM books
WHERE published_year > 2010;
Enter fullscreen mode Exit fullscreen mode

That query is precise. The database returns only what the page needs.

However, many ORM calls that load complete entities generate something closer to this:

SELECT *
FROM books
WHERE published_year > 2010;
Enter fullscreen mode Exit fullscreen mode

Now every summary is returned as well. If the result contains thousands of books, several kilobytes of unused text per row can become tens of megabytes transferred from the database and allocated inside the application.

The application never reads the summaries, but it still pays the cost of fetching and materializing them.

This behavior is not limited to one language or framework. Full-entity hydration is a common ORM default. Some persistence tools provide deferred or lazy scalar fields, while others make column selection a decision that must be repeated in each query.

That difference in framework design can influence the database design.

Does the database already handle large text efficiently?

Usually, yes.

Modern relational databases have mechanisms for handling large text and binary values without treating every byte as part of a small, fixed-width row. The exact strategy depends on the database engine, value size, and storage configuration, see MySQL InnoDB Row Formats and PostgreSQL TOAST.

More importantly, when a query requests only id and title, the database does not need to return the summary column to the application.

So the main issue is not simply that the table contains text. The issue is that the application layer may continue asking for that text when it is not needed.

This distinction matters. Moving every TEXT field into another table would add joins, relationships, migrations, and lifecycle rules without necessarily improving anything. Vertical partitioning becomes useful only when the access pattern and the framework behavior make the original layout costly or difficult to control.

Why explicit column selection may not be enough

The direct solution is to select only the required columns:

SELECT id, title, author, published_year
FROM books;
Enter fullscreen mode Exit fullscreen mode

Most ORMs also provide a way to express this. Technically, that may solve the problem.

The difficulty is consistency. Every new list page, API endpoint, export, scheduled job, or relationship query becomes another place where someone must remember to exclude the large field.

That approach can work well when:

  • the project has clear query conventions;
  • the data-access layer centralizes projections;
  • performance tests detect accidental full-row loading; or
  • the ORM supports deferred fields as part of the model mapping.

It becomes less reliable when column selection is repeated throughout a large codebase and the framework provides no model-level way to declare that a field is rarely needed.

In that situation, the risk is not that developers are careless. The risk is that the safer behavior is optional, while the expensive behavior is the default.

When a separate table becomes the safer option

Suppose the summary has the following characteristics:

  • it is significantly larger than the other book attributes;
  • it is needed only on a small number of screens;
  • books are frequently loaded in lists or relationships;
  • the ORM normally hydrates every mapped column; and
  • there is no reliable project-wide mechanism for deferring the field.

These conditions make vertical partitioning a reasonable option.

-- Frequently accessed book data
CREATE TABLE books (
    id               BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title            VARCHAR(255) NOT NULL,
    author           VARCHAR(255) NOT NULL,
    isbn             VARCHAR(20) NOT NULL,
    published_year   SMALLINT UNSIGNED NOT NULL,
    created_at       TIMESTAMP NULL DEFAULT NULL,
    updated_at       TIMESTAMP NULL DEFAULT NULL,

    INDEX idx_books_published_year (published_year)
);

-- Less frequently accessed large content
CREATE TABLE book_contents (
    book_id     BIGINT UNSIGNED PRIMARY KEY,
    summary     TEXT NOT NULL,
    created_at  TIMESTAMP NULL DEFAULT NULL,
    updated_at  TIMESTAMP NULL DEFAULT NULL,

    CONSTRAINT fk_book_contents_book
        FOREIGN KEY (book_id) REFERENCES books(id)
        ON DELETE CASCADE
);
Enter fullscreen mode Exit fullscreen mode

The table split does not make the model more normalized. The original table was already valid from a normalization perspective.

Instead, the split organizes the data by access pattern:

  • books contains the attributes that are loaded frequently;
  • book_contents contains the heavier content that is requested less often.

This makes accidental retrieval harder. A query against books cannot return the summary because the column is no longer there.

A Laravel example, but not a Laravel-only problem

In Eloquent, the relationship could be represented like this:

class Book extends Model
{
    public function content()
    {
        return $this->hasOne(BookContent::class, 'book_id');
    }
}

class BookContent extends Model
{
    protected $table = 'book_contents';
    protected $primaryKey = 'book_id';
    public $incrementing = false;

    public function book()
    {
        return $this->belongsTo(Book::class, 'book_id');
    }
}
Enter fullscreen mode Exit fullscreen mode

The common listing query loads only the smaller table:

$books = Book::where('published_year', '>', 2010)->get();
Enter fullscreen mode Exit fullscreen mode

The details page requests the content explicitly:

$book = Book::with('content')->findOrFail($id);
Enter fullscreen mode Exit fullscreen mode

Laravel is only the implementation used in this example. The same architectural pressure can appear in any stack where complete entities are loaded by default and individual fields cannot be deferred clearly at the mapping level.

If another ORM already provides reliable lazy or deferred scalar fields, keeping the summary in books may remain the simpler and better design.

Why the shared primary key matters

In book_contents, book_id is both the primary key and the foreign key.

book_id BIGINT UNSIGNED PRIMARY KEY
Enter fullscreen mode Exit fullscreen mode

This guarantees that each book can have at most one matching content row. It also avoids adding an unrelated surrogate identifier to a table whose identity is entirely dependent on the book.

The relationship is one-to-one because the database enforces it, not only because the application expects it.

However, another design decision remains: why does book_contents reference books? Why not store a content_id inside books and reverse the foreign-key direction?

Both layouts can represent a one-to-one relationship, but they differ in insert order, optionality, deletion behavior, and orphan management. That choice deserves a separate discussion.

The takeaway

Keeping a large text field in its original table is often completely reasonable. It is simple, cohesive, and supported efficiently by modern databases.

The design becomes questionable when the field is rarely needed, frequently fetched by accident, and difficult to defer because of the framework or ORM being used.

At that point, the technology has created a constraint that the schema may need to address. Moving the field into a one-to-one companion table can turn an optional query convention into a structural guarantee.

That is the real lesson: do not split a table merely because a column contains text. Split it when data size, access frequency, and framework behavior consistently work against the original design.

Part II examines the next decision: where the foreign key should live in a one-to-one vertical partition, what each direction communicates, and which failure modes appear when the relationship is modeled incorrectly.

Top comments (0)