REST Framework tries to have reasonable defaults, but a few knobs are worth turning deliberately in production. This section collects the performance-relevant settings from across the other sections into one place, with guidance on when to use each.
Collection associations (has_many, has_and_belongs_to_many) are capped at
association_limit (10) records each by default, bounding response size and query time. Set
it to change the cap (or nil to serialize all records):
class ApiController < ApplicationController
include RESTFramework::Controller
propagate do
self.association_limit = 20
end
end
When a limit is set, the framework stops eager-loading via includes (as includes would load
every row regardless of limit) and instead issues a per-record query with .limit(n). This trades
the eager-load optimization for a guarantee of bounded response size — use it only for associations
that really can be large.
Clients can raise the limit for individual associations when the controller opts into
enable_association_queries, via ?associations.<name>.limit=N (bounded by
association_limit_max) — see
Consumer-Requested Association Fields.
If you need an accurate total as well as a bounded preview, enable:
self.include_association_count = true
This adds a <assoc>.count field to each record. Note that this triggers a COUNT(*) per record
per association — it’s convenient but not free. For large collections, prefer a single
“totals” extra action over per-record counts.
For associations rendered without a limit, the framework uses includes(...) to eager-load them.
Combined with filter backends that may also add includes, most N+1s are avoided
automatically. That said:
fields (not associations) that themselves load records will cause N+1s. The
framework has no way to detect this.hidden_from_index so they only run on show.QueryFilter, OrderingFilter, and SearchFilter default to operating over every field in
fields. For internal administrative APIs this is fine. For public APIs, narrow the attack
surface:
class Api::MoviesController < ApiController
self.model = Movie
self.fields = [ :id, :name, :release_date, :internal_notes, :director ]
# Only these fields can be filtered, ordered, or searched by clients.
self.filter_fields = [ :name, :release_date, :director ]
self.ordering_fields = [ :name, :release_date ]
self.search_fields = [ :name ]
end
Why this matters:
?internal_notes_cont=... on a non-indexed TEXT column).SearchFilter uses LIKE '%...%' which can’t use a normal index — constrain it to columns
you’ve explicitly indexed (e.g., with a gin or pg_trgm index on PostgreSQL).An unpaginated index on a large table is a classic production bug, so pagination is on by default
(PageNumberPaginator, page_size 20, max_page_size 40) — bounded out of the box. Tune the page
size and cap at the top of the inheritance hierarchy. Configuration assignments are local by
default; wrap them in a propagate block to share them with descendant controllers:
class ApiController < ApplicationController
include RESTFramework::Controller
propagate do
self.page_size = 30
self.max_page_size = 100 # Ceiling on `?page_size=...`; caps `?page_size=1000000`.
end
end
The max_page_size cap also disables the ?page_size=0 “unpaginated” escape hatch (set
max_page_size = nil to allow it) — worth the tradeoff in production for APIs where no client
should be able to dump the full table.
Some has_many associations point at tables with millions of rows (audit logs, events, page
views). Exposing them as default fields makes default serialization catastrophically expensive.
Register them globally so they’re excluded from the defaults:
# config/initializers/rest_framework.rb
RESTFramework.configure do |config|
config.large_reverse_association_tables = %w[ events audit_logs page_views ]
end
You can still opt a specific controller in by listing the field in fields.
For controllers that don’t need associations in the default field set, opt out:
class Api::AdminStatsController < ApiController
self.model = AuditEvent
self.exclude_associations = true
end
This is roughly equivalent to self.fields = Model.column_names - foreign_keys, but keeps the
field set in sync with the schema automatically.
filter_recordset_before_find = falseSetting filter_recordset_before_find = false skips filter application when looking up single
records (show, update, destroy). That avoids a small amount of filter work but also means
records hidden by filters become accessible via direct ID lookup — which is usually a bug, not
a feature. Default (true) is almost always what you want.
If your API doesn’t need XML (many don’t), turn it off:
class ApiController < ApplicationController
include RESTFramework::Controller
propagate do
self.serialize_to_xml = false
end
end
The framework skips the XML render path entirely, saving a small amount of work per request.
Similarly, serialize_to_json = false disables JSON.
If you have a service-to-service API that no human will ever browse:
class Api::InternalController < ApiController
self.rescue_unknown_format_with = nil
before_action do
head(:not_acceptable) and return if request.format.html?
end
end
This avoids rendering the full HTML layout, and prevents the API from honoring Accept:
text/html.
By default the browsable API loads Bootstrap, highlight.js, Trix, and friends from public CDNs with SRI integrity. If your deploys are air-gapped or behind strict CSP, serve the assets yourself:
RESTFramework.configure do |config|
config.use_vendored_assets = true
end
Requires Propshaft (default on Rails 8+) or Sprockets. The vendored files ship with the gem.
The OpenAPI document is computed from live reflections, which isn’t cheap. If many clients fetch it, consider caching with HTTP caching headers:
class Api::MoviesController < ApiController
def options
expires_in(5.minutes, public: true)
super
end
end
Or, for a long-lived public document, serve a snapshotted version from a separate endpoint (see the OpenAPI section).
A short list for going to production:
RESTFramework.config.large_reverse_association_tables set for expensive tablespage_size / max_page_size reviewed for your workload (pagination and a max_page_size
cap are on by default; tune them at the root inside a propagate block)filter_fields, ordering_fields, and search_fields whitelisted for public APIsassociation_limit reviewed if any controller exposes large collection associationsserialize_to_xml = false if you don’t serve XMLuse_vendored_assets = true if you can’t reach public CDNs