Database Scalability: Limit on-disk table size to < 100 GB for

This document is a proposal to work towards reducing and limiting table sizes on We establish a measurable target by limiting table size to a certain threshold. This is used as an indicator to drive database focus and decision making. With growing, we continuously re-evaluate which tables need to be worked on to prevent or otherwise fix violations.

This is not meant to be a hard rule but rather a strong indication that work needs to be done to break a table apart or otherwise reduce its size.

This is meant to be read in context with the Database Sharding blueprint, which paints the bigger picture. This proposal here is thought to be part of the “debloating step” below, as we aim to reduce storage requirements and improve data modeling. Partitioning is part of the standard tool-belt: where possible, we can already use partitioning as a solution to cut physical table sizes significantly. Both help to prepare efforts like decomposition (database usage is already optimized) and sharding (database is already partitioned along an identified data access dimension).

graph LR Fe(Pick feature) --> D D[(Database)] --> De De[Debloating] --> Dc Dc[Decomposition] --> P P[Partitioning] --> S S[Sharding] --> R P --> M M[Microservices] --> R R{Repeat?} --> Fe style De fill:#fca326 style P fill:#fc6d26

Motivation: stability and performance

Large tables on are a major problem - for both operations and development. They cause a variety of problems:

  1. Query timings and hence overall application performance suffers
  2. Table maintenance becomes much more costly. Vacuum activity has become a significant concern on - with large tables only seeing infrequent (once per day) processing and vacuum runs taking many hours to complete. This has various negative consequences and a very large table has potential to impact seemingly unrelated parts of the database and hence overall application performance suffers.
  3. Data migrations on large tables are significantly more complex to implement and incur development overhead. They have potential to cause stability problems on and take a long time to execute on large datasets.
  4. Indexes size is significant. This directly impacts performance as smaller parts of the index are kept in memory and also makes the indexes harder to maintain (think repacking).
  5. Index creation times go up significantly - in 2021, we see B-Tree creation take up to 6 hours for a single B-Tree index. This impacts our ability to deploy frequently and leads to vacuum-related problems (delayed cleanup).
  6. We tend to add many indexes to mitigate, but this eventually causes significant overhead, can confuse the query planner and a large number of indexes is a smell of a design problem.


Most prominently, the ci_builds table is 1.5 TB in size as of June 2021 and has 31 indexes associated with it which sum up to 1 TB in size. The overall on-disk size for this table is 2.5 TB. Currently, this grows at 300 GB per month. By the end of the year, this is thought to be close to 5 TB if we don’t take measures against.

The following examples show that very large tables often constitute the root cause of incidents on

  1. Infrequent and long running vacuum activity has led to repeated degradation of query performance for CI queuing
  2. On large tables like ci_builds, index creation time varies between 1.5 to 6 hours during busy times. This process blocks deployments as migrations are being run synchronously - reducing our ability to deploy frequently.
  3. Creating a large index can lead to a burst of activity on the database primary:
    1. on merge_request_diff_commits table: caused high network saturation,
    2. regular reindexing activity on the weekend: causes growing WAL queue (impacts recovery objectives),
    3. notes table: Re-creating a GIN trigram index for maintenance reasons has become nearly unfeasible and had to be aborted after 12 hours upon first try as it was blocking other vacuum operation.

Problematic tables on

This shows the TOP30 tables by their total size (includes index sizes) as of mid June 2021 on table_size, index_size is the on-disk size of the actual data and associated indexes, respectively. percentage_of_total_database_size displays the ratio of total table size to database size.

As we can see, there are currently very large tables greater than 1 TB in size, which also tend to have very large indexes.

The other observation here is that there are also tables with a large number of indexes and total index size can be significantly larger than the data stored. For example, deployments is 30 GB in size plus additional 123 GB of index data spread across 24 indexes.

TableTotal sizeTable sizeIndex sizeIndex countPercentage of total database size
ci_builds2975 GB1551 GB941 GB3022.7
merge_request_diff_commits1890 GB1454 GB414 GB214.4
ci_build_trace_sections1123 GB542 GB581 GB38.6
notes748 GB390 GB332 GB135.7
merge_request_diff_files575 GB481 GB88 GB14.4
events441 GB95 GB346 GB123.4
ci_job_artifacts397 GB187 GB210 GB103.0
ci_pipelines266 GB66 GB200 GB232.0
taggings238 GB60 GB179 GB51.8
ci_builds_metadata237 GB88 GB149 GB51.8
issues219 GB47 GB150 GB281.7
web_hook_logs_202103186 GB122 GB8416 MB31.4
ci_stages182 GB58 GB124 GB61.4
web_hook_logs_202105180 GB115 GB7868 MB31.4
merge_requests176 GB44 GB125 GB361.3
web_hook_logs_202104176 GB115 GB7472 MB31.3
web_hook_logs_202101169 GB112 GB7231 MB31.3
web_hook_logs_202102167 GB111 GB7106 MB31.3
sent_notifications166 GB88 GB79 GB31.3
web_hook_logs_202011163 GB113 GB7125 MB31.2
push_event_payloads162 GB114 GB48 GB11.2
web_hook_logs_202012159 GB106 GB6771 MB31.2
web_hook_logs_202106156 GB101 GB6752 MB31.2
deployments155 GB30 GB125 GB241.2
web_hook_logs_202010136 GB98 GB6116 MB31.0
web_hook_logs_202009114 GB82 GB5168 MB30.9
security_findings109 GB21 GB88 GB80.8
web_hook_logs_20200892 GB66 GB3983 MB30.7
resource_label_events66 GB47 GB19 GB60.5
merge_request_diffs63 GB39 GB22 GB50.5

Target: All physical tables on are < 100 GB including indexes

In PostgreSQL context, a physical table is either a regular table or a partition of a partitioned table.

In order to maintain and improve operational stability and lessen development burden, we target a table size less than 100 GB for a physical table on (including its indexes). This has numerous benefits:

  1. Improved query performance and more stable query plans
  2. Significantly reduce vacuum run times and increase frequency of vacuum runs to maintain a healthy state - reducing overhead on the database primary
  3. Index creation times are significantly faster (significantly less data to read per index)
  4. Indexes are smaller, can be maintained more efficiently and fit better into memory
  5. Data migrations are easier to reason about, take less time to implement and execute

This target is pragmatic: We understand table sizes depend on feature usage, code changes and other factors - which all change over time. We may not always find solutions where we can tightly limit the size of physical tables once and for all. That is acceptable though and we primarily aim to keep the situation on under control. We adapt our efforts to the situation present on and re-evaluate frequently.

While there are changes we can make that lead to a constant maximum physical table size over time, this doesn’t need to be the case necessarily. Consider for example hash partitioning, which breaks a table down into a static number of partitions. With data growth over time, individual partitions also grow in size and may eventually reach the threshold size again. We strive to get constant table sizes, but it is acceptable to ship easier solutions that don’t have this characteristic but improve the situation for a considerable amount of time.

As such, the target size of a physical table after refactoring depends on the situation and there is no hard rule for it. We suggest to consider historic data growth and forecast when physical tables reach the threshold of 100 GB again. This allows us to understand how long a particular solution is expected to last until the model has to be revisited.


There is no standard solution to reduce table sizes - there are many!

  1. Retention: Delete unnecessary data, for example expire old and unneeded records.
  2. Remove STI: We still use single-table inheritance in a few places, which is considered an anti-pattern. Redesigning this, we can split data into multiple tables.
  3. Index optimization: Drop unnecessary indexes and consolidate overlapping indexes if possible.
  4. Optimise data types: Review data type decisions and optimise data types where possible (example: use integer instead of text for an enum column)
  5. Partitioning: Apply a partitioning scheme if there is a common access dimension.
  6. Normalization: Review relational modeling and apply normalization techniques to remove duplicate data
  7. Vertical table splits: Review column usage and split table vertically.
  8. Externalize: Move large data types out of the database entirely. For example, JSON documents, especially when not used for filtering, may be better stored outside the database, for example, in object storage.
While we’re targeting to limit physical table sizes, we consider retaining or improving performance a goal, too.

For solutions like normalization, this is a trade-off: Denormalized models can speed up queries when used appropriately, at the expense of table size. When normalizing models, splitting tables or externalizing data, we aim to understand the impact on performance and strive to find a solution to reduce table sizes that doesn’t impact performance significantly.

Example efforts

A few examples can be found below, many more are organized under the epic Database efficiency.

  1. Reduce number of indexes on ci_builds
  2. Normalize and de-duplicate committer and author details in merge_request_diff_commits
  3. Retention strategy for ci_build_trace_sections
  4. Implement worker that hard-deletes old CI jobs metadata
  5. merge_request_diff_files violates < 100 GB target (epic)


The epic for ~group::database drives decision making to establish and communicate the target and to identify and propose necessary changes to reach it. Those changes should primarily be driven by the respective stage group owning the data (and the feature using it), with ~group::database to support.


Identifying solutions for offending tables is driven by the GitLab Database Team and respective stage groups.

AuthorAndreas Brandl
Engineering LeaderCraig Gomes