Skip to content

fix: N+1 query on GET /api/users list endpoint [#9761]#9762

Open
oallw wants to merge 1 commit into
HumanSignal:developfrom
oallw:fix/9761-user-api-n+1
Open

fix: N+1 query on GET /api/users list endpoint [#9761]#9762
oallw wants to merge 1 commit into
HumanSignal:developfrom
oallw:fix/9761-user-api-n+1

Conversation

@oallw
Copy link
Copy Markdown

@oallw oallw commented May 26, 2026

Description

Fix for issue N+1 query on GET /api/users list endpoint #9761

GET /api/users suffers from N+1 queries. For each user returned, BaseUserSerializer triggers additional queries to resolve:

  1. active_organization (FK on User) - 1 query per user
  2. active_organization.created_by (FK on Organization) - 1 query per user
  3. om_through (reverse FK, OrganizationMember) - 1 query per user

For an org with 151 users, this produces 605 queries and ~1.5s response time. The query count grows linearly with the number of users.

This is the same pattern that was fixed for the organization members endpoint in PR #7461.

Fix

Add select_related and prefetch_related to UserAPI.get_queryset():

def get_queryset(self):
    return (
        User.objects.filter(organizations=self.request.user.active_organization)
        .select_related('active_organization', 'active_organization__created_by')
        .prefetch_related('om_through')
    )

This reduces the query count to a constant 3 queries regardless of user count:

  1. Auth/permission check
  2. Main user query with JOINs (select_related)
  3. Batch prefetch of organization memberships (prefetch_related)

Reproduction

from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
    response = client.get('/api/users')
print(f'{len(ctx.captured_queries)} queries for {len(response.json())} users')
# Before: 605 queries for 151 users
# After:    3 queries for 151 users

Acceptance Criteria

  • When a user calls GET /api/users, the number of SQL queries is constant regardless of how many users are in the organization.
  • When a user calls GET /api/users, the active_organization_meta field is correctly populated for each user.
  • When a user calls GET /api/users and a member has been soft-deleted, that user appears as "Deleted User".
  • When a user calls GET /api/users/<id>, the response is unchanged.

Tests

Added label_studio/users/tests/test_users_api.py with 4 test cases:

Test What it verifies
test_list_query_count_does_not_scale_with_user_count Query count stays constant (3) when scaling from 6 to 26 users
test_list_returns_active_organization_meta active_organization_meta contains title and email for all users
test_list_deleted_member_shown_as_deleted Soft-deleted org members render as "Deleted User"
test_retrieve_still_works Single-user retrieve endpoint is unaffected

All tests pass on both sqlite and postgresql (tested locally against postgres:16-alpine).

Note on tavern: Contributing guidelines recommend tavern for API endpoint tests. We used Django TestCase with assertNumQueries instead because the core assertion — that query count is constant regardless of user count — cannot be expressed in tavern. The tests still exercise the full DRF request/response cycle via APIClient.

Risks

Low risk. The change is a single queryset optimization — no new models, migrations, endpoints, or behavioral changes.

  • select_related / prefetch_related are standard Django ORM and database-agnostic. They change how queries are batched, not what data is returned.
  • The same pattern was applied to the organization members endpoint in feat: OPTIC-2002: Improve Organization Members endpoints performance #7461 without issues.
  • get_queryset() is shared by all UserAPI actions (list, retrieve, create, update, delete). The extra JOINs and prefetch add negligible overhead for single-object operations (retrieve, update, delete) and are a net improvement for list.
  • No feature flag needed — this is a pure performance fix with no behavioral change to the API response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@netlify
Copy link
Copy Markdown

netlify Bot commented May 26, 2026

👷 Deploy request for label-studio-docs-new-theme pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit cf9a323

@netlify
Copy link
Copy Markdown

netlify Bot commented May 26, 2026

👷 Deploy request for heartex-docs pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit cf9a323

@netlify
Copy link
Copy Markdown

netlify Bot commented May 26, 2026

Deploy Preview for label-studio-playground canceled.

Name Link
🔨 Latest commit cf9a323
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-playground/deploys/6a15969947052e0007487981

@netlify
Copy link
Copy Markdown

netlify Bot commented May 26, 2026

Deploy Preview for label-studio-storybook canceled.

Name Link
🔨 Latest commit cf9a323
🔍 Latest deploy log https://app.netlify.com/projects/label-studio-storybook/deploys/6a159699c4dcb6000803d246

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant