GSoC 2026: Add support for generate_series in postgres (Draft Proposal Feedback)

Hi,
I am Samriddha. I am interested in distributed systems and understanding how databases work internally, which is why I enjoy working on ORM-related issues in Django.
This is my open source work till now (mainly in Django) - GitHub - Samriddha9619/github-open-source-logs · GitHub
I’m putting together my GSoC 2026 proposal for the “Add support for
generate_series in postgres” project . Before I finalize my proposal, I wanted to get early
feedback on my implementation approach.

The Bug

When .annotate(val=GenerateSeries(1, 5)).filter(val__gt=3) is called,
build_filter() calls solve_lookup_type()refs_expression(),
which returns the raw GenerateSeries expression as
reffed_expression. The early return path in build_filter() then
compiles generate_series(1, 5) directly into the WHERE clause.
PostgreSQL raises ProgrammingError because SRFs are not allowed
in WHERE.

The root cause: add_annotation() doesn’t inspect the
set_returning flag. All annotations are treated as SELECT-clause
values.

Proposed Architecture

The fix point is Query.add_annotation(). When set_returning=True
is detected:

  1. Route the expression to a new TableValuedFunction AST node in
    alias_map (alongside Join and BaseTable in
    datastructures.py).
  2. Replace the annotation with a Col reference to the FROM-clause
    alias.
  3. Subsequent refs_expression calls return the Col, so the WHERE
    clause gets "val"."value" > 3 instead of the raw function.
User writes:       .annotate(series=GenerateSeries(1, 10)).filter(series__gt=5)

                            │                                        │
                            ▼                                        │
              Query.add_annotation()                                 │
              detects set_returning=True                             │
                            │                                        │
              ┌─────────────┴──────────────┐                         │
              ▼                            ▼                         │
     alias_map["series"] =       annotations["series"] =             │
    TableValuedFunction(        Col("series", IntegerField)          │
       generate_series(1,10),                                        │
       alias="series"            ◄───────────────────────────────────┘
    )                           resolve_ref("series") returns Col
              │                 compiler emits: "series"."value" > 5
              ▼
    get_from_clause() emits:
    , generate_series(1, 10) AS "series"("value")

This requires no new QuerySet methods everything goes through
.annotate(). get_from_clause() already iterates over alias_map
and calls compile() on each entry, so the TableValuedFunction
node integrates with zero changes to the compiler’s as_sql().

I’m scoping this to single-column SRFs (generate_series, unnest)
first, leaving multi-column functions like json_each for a
follow-up phase per Lily’s comments on new-features Issue #25
about alias management for composite return types.

The core routing lives in django.db.models.sql, not
contrib.postgres, so the infrastructure is reusable for other
backends’ SRFs in the future.

My question:

Query.join() handles deduplication and join-type promotion via
JoinPromoter, but expects a Join object with join_field and
join_cols attributes that don’t exist for a virtual table source.
Should I adapt join() to accept TableValuedFunction nodes, or is
direct alias_map insertion with manual refcount management the
cleaner path?

Looking forward to any feedback and pointers.

Thanks.

Following recent feedback on this forum post - Post
I’ve restructured my proposal to prioritize the foundational ORM primitives rather than jumping directly to SRF routing.

Revised phased approach:

  1. CompositeField — Enabling expressions to declare tuple return types. The existing ColPairs (composite PK support) and allows_composite_expressions flag show the ORM is already moving in this direction.
  2. output_field on alias_map members — Having BaseTable and Join define their own output_field so that lookup resolution in names_to_path() can transition from self.modelself.base_table.output_field. This delegates SQL generation to expressions instead of hardcoding it in the compiler.
  3. SRF support via LATERAL JOIN — With the above foundations, a TableValuedFunction alias_map member naturally integrates: add_annotation() detects set_returning=True, routes the expression to alias_map with a CompositeField output, and replaces the annotation with a Col reference. The compiler emits LATERAL generate_series(...) in the FROM clause.

One specific question: Once Phase 2 makes alias_map members self-describing via

output_field, would you expect TableValuedFunction to integrate through Query.join() (with a lightweight adaptation for members that lack join_cols), or is direct alias_map insertion with manual refcount management the cleaner approach? TVFs don’t participate in join deduplication or promotion, so I lean toward direct insertion — but I’d value your perspective on long-term maintainability.

Updated AST ( Abstract Syntax Tree )

User writes:
.annotate(series=GenerateSeries(1, 10)).filter(series__gt=5)
                               │
                               ▼
                  ┌────────────────────────┐
                  │ Query.add_annotation() │
                  │ (set_returning=True)   │
                  └────────────┬───────────┘
                               │
            ┌──────────────────┴──────────────────┐
            ▼                                     ▼
  [Phase 2: FROM clause]                [Phase 1: SELECT/WHERE]
  alias_map["series"] =                 annotations["series"] =
  TableValuedFunction(                  Col("series",
    expr=GenerateSeries(1, 10),           CompositeField(
    alias="series",                         IntegerField("value")
    output_field=CompositeField(          )
      IntegerField("value")             )
    )
  )                                               │
            │                                     │
            ▼                                     ▼
  get_from_clause() iterates            resolve_ref("series")
  alias_map, calls as_sql()             returns the Col object
            │                                     │
            ▼                                     ▼
  LATERAL generate_series(1, 10)        "series"."value" > 5
  AS "series"("value")
 
 ─────────────────────────────────────────────────────────────
 FINAL SQL:
 SELECT "myapp_model"."id", "series"."value"
 FROM "myapp_model",
      LATERAL generate_series(1, 10) AS "series"("value")
 WHERE "series"."value" > 5
 ─────────────────────────────────────────────────────────────