Skip to content

Conversation

@SchrodingersGat
Copy link
Member

@SchrodingersGat SchrodingersGat commented Jan 1, 2026

This PR is a major refactor of the enable_filter functionality which allows optional fields to be added (or removed) dynamically to API serializers.

Problem Description

The old code was written in such a way that the optional serializer class was instantiated on definition.

Consider the following code

    category_detail = enable_filter(
        CategorySerializer(
            source='category', many=False, read_only=True, allow_null=True
        ),
        prefetch_fields=['category'],
    )

The CategorySerializer class is instantiated (which is quite expensive) - and then this is also chained down for any multi-level child serializers.

In many cases, the fields are later removed, so instantiating them early is a waste of resources.

Also, due to the (wasteful?) way that DRF deep-copies all the fields (multiple times throughout the lifespan of the serializer) this can be very prohibitive.

Some particularly bad API endpoints which had deep nested serializers, saw hundreds of thousands of serializer objects created, and then later deleted.

Solution

The PR introduces an OptionalField approach, which lazily evaluates the optional fields only after we have decided that they should definitely be included in the serializer:

    category_detail = OptionalField(
        serializer_class=CategorySerializer,
        serializer_kwargs={
            'source': 'category',
            'many': False,
            'read_only': True,
            'allow_null': True,
        },
        prefetch_fields=['category'],
    )

Justification

The new API endpoints are significantly faster, we have been introducing a huge amount of wasted overhead (for years now in the codebase) due to the unnecessary serializer evaluation

Benchmarks

Benchmarking shows that serializers which are deeply nested have the most benefit from this PR. Both GET and OPTIONS requests are improved substantially by deferring serializer instantiation.

Methodology

  • Perform external requests via python API bindings
  • Average across 100 requests per test
  • All times specified in ms

GET

  • limit=50
  • offset=0
URL Master PR
/api/part/ 151 89
/api/part/category/ 43 43
/api/stock/ 173 74
/api/stock/location/ 64 42
/api/company/ 47 38
/api/build/ 86 47
/api/build/line/ 646 351
/api/build/item/ 276 41
/api/order/so/ 75 50
/api/order/so/shipment/ 90 79
/api/order/po/ 89 50
/api/order/po-line/ 187 62
/api/user/roles/ 32 37
/api/parameter/ 42 36
/api/parameter/template/ 34 33

OPTIONS

URL Master PR
/api/part/ 85 71
/api/part/category/ 43 33
/api/stock/location/ 42 37
/api/company/ 43 36
/api/build/ 72 61
/api/build/line/ 266 44
/api/build/item/ 210 31
/api/order/so/ 67 60
/api/order/so/shipment/ 70 50
/api/order/po/ 66 57
/api/order/po-line/ 185 60
/api/user/roles/ 28 30
/api/parameter/ 36 35
/api/parameter/template/ 30 33

Search

URL Master PR
/api/search/ 381 294

@SchrodingersGat SchrodingersGat added this to the 1.2.0 milestone Jan 1, 2026
@SchrodingersGat SchrodingersGat added api Relates to the API refactor labels Jan 1, 2026
@netlify
Copy link

netlify bot commented Jan 1, 2026

Deploy Preview for inventree-web-pui-preview ready!

Name Link
🔨 Latest commit f8cab0e
🔍 Latest deploy log https://app.netlify.com/projects/inventree-web-pui-preview/deploys/695d7078f4986800082c100f
😎 Deploy Preview https://deploy-preview-11073--inventree-web-pui-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 90 (🔴 down 4 from production)
Accessibility: 81 (no change from production)
Best Practices: 100 (no change from production)
SEO: 78 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the enable_filter functionality to use a new OptionalField dataclass approach for lazy evaluation of optional serializer fields, addressing performance issues caused by premature serializer instantiation.

Key changes:

  • Replaced enable_filter() function with OptionalField dataclass for declarative field definitions
  • Refactored FilterableSerializerMixin to lazily instantiate optional fields only when needed
  • Updated all serializers across the codebase to use the new OptionalField pattern

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
InvenTree/serializers.py Introduced OptionalField dataclass and refactored FilterableSerializerMixin to support lazy field initialization via build_unknown_field()
InvenTree/test_serializers.py Updated tests to use new OptionalField API; removed obsolete enable_filter validation test; corrected spelling from "failiure" to "failure"
users/serializers.py Migrated GroupSerializer fields (permissions, roles, users) from enable_filter to OptionalField
stock/serializers.py Converted multiple optional detail fields including user_detail, template_detail, location_path, part_detail, supplier_part_detail, tests, item_detail to use OptionalField
part/serializers.py Refactored optional fields across CategorySerializer, PartBriefSerializer, PartSerializer, BomItemSerializer, and related serializers to use OptionalField pattern
order/serializers.py Updated order-related serializers including purchase order, sales order, and return order serializers to use OptionalField for detail fields
company/serializers.py Migrated company, manufacturer part, and supplier part serializers' optional fields to OptionalField
common/serializers.py Converted parameter serializer detail fields to use OptionalField
common/filters.py Updated filter helper functions to return OptionalField instances instead of enable_filter wrapped fields
build/serializers.py Refactored build serializer optional fields including part, user, and item detail fields to use OptionalField

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov
Copy link

codecov bot commented Jan 1, 2026

Codecov Report

❌ Patch coverage is 97.60274% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.15%. Comparing base (5b290f4) to head (f8cab0e).

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11073      +/-   ##
==========================================
- Coverage   88.16%   88.15%   -0.01%     
==========================================
  Files        1290     1290              
  Lines       58145    58173      +28     
  Branches     1969     1969              
==========================================
+ Hits        51261    51285      +24     
- Misses       6393     6397       +4     
  Partials      491      491              
Flag Coverage Δ
backend 89.45% <97.60%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
Backend Apps 92.02% <99.31%> (+<0.01%) ⬆️
Backend General 93.48% <100.00%> (+<0.01%) ⬆️
Frontend 70.85% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@matmair
Copy link
Contributor

matmair commented Jan 1, 2026

I might be overlooking something, but are we not completely loosing static introspection with this change? I am not sure if there is much actual runtime performance to be gained by this, have you benchmarked it?

@SchrodingersGat
Copy link
Member Author

I am not sure if there is much actual runtime performance to be gained by this, have you benchmarked it?

Benchmarks added to the top comment.

I might be overlooking something, but are we not completely loosing static introspection with this change?

We should not lose any introspection as the fields are still created - they are just defered until we determine they are actually needed. This is most important in the case of deeply nested serializer fields which will never be exposed to the final serializer tree.

The OPTIONS endpoints still show all the optional fields, and I believe the API docs should be the same. LMK if you spot any items which are no longer included.

- Handle case where optional field shadows model property
- Consider read_only and write_only fields
- Handle case where optional field shadows model relation
@SchrodingersGat SchrodingersGat added the full-run Always do a full QC CI run label Jan 2, 2026
Comment on lines +65 to +66
This is used in conjunction with the `FilterableSerializerMixin` to allow
dynamic inclusion or exclusion of serializer fields at runtime.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstrings seem very slim compared to the replaced function

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I will add more documentation here

INVENTREE_API_TEXT = """
v436 -> 2026-01-03 : https://github.com/inventree/InvenTree/pull/11073
- Add OptionalField class for cleaner handling of optional fields in serializers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seem to be other API changes too, should they also be mentioned?

@matmair
Copy link
Contributor

matmair commented Jan 3, 2026

We should not lose any introspection as the fields are still created - they are just defered until we determine they are actually needed. This is most important in the case of deeply nested serializer fields which will never be exposed to the final serializer tree.

At least in my setups the python LSP seems to not work with this new setup. The kwargs are not validated anymore during startup and you can not rename fields on the serializer automatically because it is not inspectable throught the LSP server anymore. Type checking also seems to be lost on the kwargs.

@SchrodingersGat
Copy link
Member Author

At least in my setups the python LSP seems to not work with this new setup. The kwargs are not validated anymore during startup and you can not rename fields on the serializer automatically because it is not inspectable throught the LSP server anymore. Type checking also seems to be lost on the kwargs.

can you please provide some specific examples of differences, perhaps we can add some additional unit tests? The speed improvements make the UX much snappier, so I'm keen to move this forward but want to make sure you are still happy with it.

@SchrodingersGat
Copy link
Member Author

As an interesting observation - the speed "penalty" which we are incurring by instantiating optional fields and then later removing them has been in the code base from the beginning, it was not introduced by the enable_filter refactor.

@matmair
Copy link
Contributor

matmair commented Jan 4, 2026

can you please provide some specific examples of differences, perhaps we can add some additional unit tests?

I am not sure if we really should test against any specific python LSP implementation; this is a fundamental design decision / issue that causes this - not a specific LSP implementation detail

Due to only passing a formless dict into this new function, we lose any type inference from the serializer class. If you insist, I can paste a screenshot from VS Code showing no refactor options etc. but showing something not existing is not adding much context.

you are still happy with it.

This diverges from how Django Rest Framework works even more than we already do, I am not sure anyone outside of core devs will grasp how this works in a few weeks. It breaks a lot of development UX and makes the request/response cycle even more difficult to understand. I am not sure it is worth it

As an interesting observation - the speed "penalty" which we are incurring by instantiating optional fields and then later removing them has been in the code base from the beginning, it was not introduced by the enable_filter refactor.

A serializer being initialized is part of the basic design of DRF. I purposefully did not touch it when I added enable_filter.

If we really want to change the fundamental behavior of DRF we could refactor the whole mechanism similar to https://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields or https://github.com/khaledsukkar2/drf-shapeless-serializers

@SchrodingersGat
Copy link
Member Author

SchrodingersGat commented Jan 5, 2026

I think that this implementation is pretty similar to the concepts in drf-shapeless-serializers (which I had not known of before).

It breaks a lot of development UX and makes the request/response cycle even more difficult to understand.

I do not think that this is any more difficult to understand (from a developer perspective) than the enable_filter approach. The enable_filter approach was a great refactor of our existing "add-then-remove" approach for nested serializers which we have had for years. However it was not until I started digging into API profiling that I discovered how much overhead this approach can add.

I am not sure it is worth it

Deeply nested serializers add a lot of overhead when responding to an API call. Consider building a table for /api/build/item in the user interface.

  • First we issue an OPTIONS request to learn the structure of the table data
  • Then we fetch the actual data with a GET request

In current master, these two requests are > 200ms each, which means that this table in particular feels very slow. Additionally, it reduces the number of requests being processed overall, which means that the UI is less snappy in general.

After this optimization, we are down to ~40ms + ~30ms in comparative testing. That's ~70ms vs ~450ms to load the table.

This is a significant speed up (that is noticable in the UI) which I would like to retain. Maybe there's a more "DRF-ey" approach?

@matmair
Copy link
Contributor

matmair commented Jan 5, 2026

While I understand the possible performance improvements, it should be at least noted that this makes it considerable harder to contribute. Even seasoned Django devs will not know this pattern and it will be in most important API endpoints.

One can not jump through the arguments with IntelliSense anymore, one loses type checking on the arguments on the serializers. In my eyes, that is a hard hit as it places more burden on the devs to know what they are doing.

I still was not able to reproduce the benchmarked improvements that drastically in my setups; are you using a live environment or one of the test datasets?

@SchrodingersGat
Copy link
Member Author

are you using a live environment or one of the test datasets?

This is with (in both test cases)

  • devcontainer development setup
  • DEBUG=True

And also reproduced with both the django built-in server and gunicorn

@codspeed-hq
Copy link

codspeed-hq bot commented Jan 6, 2026

Merging this PR will create unknown performance changes

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

Summary

🆕 29 new benchmarks
⏩ 2 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
🆕 WallTime test_api_stock_list N/A 936.3 ms N/A
🆕 WallTime test_api_part_list N/A 939.9 ms N/A
🆕 WallTime test_api_list_performance[/api/part/] N/A 79.9 ms N/A
🆕 WallTime test_api_options_performance[/api/build/line/] N/A 66.4 ms N/A
🆕 WallTime test_api_options_performance[/api/order/po-line/] N/A 72.4 ms N/A
🆕 WallTime test_api_auth_performance N/A 654.6 ms N/A
🆕 WallTime test_api_options_performance[/api/company/] N/A 19.6 ms N/A
🆕 WallTime test_api_list_performance[/api/part/category/] N/A 16.3 ms N/A
🆕 WallTime test_api_options_performance[/api/build/] N/A 52.1 ms N/A
🆕 WallTime test_api_list_performance[/api/parameter/template/] N/A 8.9 ms N/A
🆕 WallTime test_api_options_performance[/api/order/po/] N/A 43 ms N/A
🆕 WallTime test_api_list_performance[/api/build/] N/A 29.2 ms N/A
🆕 WallTime test_api_options_performance[/api/build/item/] N/A 46.3 ms N/A
🆕 WallTime test_api_options_performance[/api/part/] N/A 58.2 ms N/A
🆕 WallTime test_api_list_performance[/api/company/] N/A 15.4 ms N/A
🆕 WallTime test_api_options_performance[/api/stock/location/] N/A 15.8 ms N/A
🆕 WallTime test_api_options_performance[/api/order/so/] N/A 47.4 ms N/A
🆕 WallTime test_api_options_performance[/api/order/so/shipment/] N/A 45 ms N/A
🆕 WallTime test_api_list_performance[/api/build/item/] N/A 11.9 ms N/A
🆕 WallTime test_api_list_performance[/api/stock/] N/A 75.7 ms N/A
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.


Comparing SchrodingersGat:filter-refactor (f8cab0e) with master (025f6ff)2

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on master (5b290f4) during the generation of this report, so 025f6ff was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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

Labels

api Relates to the API full-run Always do a full QC CI run refactor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants