PostHog / PostHog/posthog

Mixed User + Group Targeting for Feature Flags Mega Issue

Open
#46,288 2 comments 1 reaction 1 assignee View on GitHub

@dmarticus is already working on this.

Since Jan 29, 2026.

Dominant language
Python
Stars
39.9k
Forks
3.4k
Avg merge
6h 51m
Merged PRs (30d)
232

Description

This issue is for tracking work related to https://github.com/PostHog/posthog/issues/33206

Context

Customers want flags like:

  • "All users in company X, plus specific users by distinct ID"
  • "The 'beta-testers' project group, plus any user with is_employee = true"
  • "50% of enterprise companies, but only pro-tier users within those companies"
  • "50% of companies OR 75% of beta users" (different rollout logic per condition)

This requires combining group-level and person-level conditions in a single flag, with per-condition-set aggregation.

Key Insight: Groups Are Event-Contextual

Groups are attached to events, not persons. A person doesn't "belong to" a group permanently — they have an active group context at evaluation time:

  • User Alice sends events with { company: "acme" } when logged into Acme
  • Same Alice sends events with { company: "beta-corp" } when logged into Beta Corp
  • The SDK already provides this context at evaluation time

This means we don't need to persist person→group membership. The caller controls which group context is active, which naturally solves the "user has multiple orgs" problem.

Design

Per-Condition-Set Aggregation

Each condition set chooses its own aggregation mode:

Mode Hash Key Rollout Means
Person distinct_id "X% of users matching this condition"
Group group_key "X% of groups matching this condition"

This enables mixed targeting like:

Condition Set 1:
  filters: group.merchant.key IN ['acme', 'beta-corp']
  rollout: 100%
  aggregation: merchant
  → "All users in these specific merchants"

Condition Set 2:
  filters: distinct_id IN ['alice', 'bob']
  rollout: 100%
  aggregation: person
  → "These specific users"

Result: user sees flag if they match EITHER condition
Model Change

Move aggregation_group_type_index from flag-level to condition-level:

# Before (flag-level)
class FeatureFlag:
    aggregation_group_type_index: Optional[int]
    filters: dict  # contains condition sets

# After (condition-level)
class FeatureFlagCondition:
    properties: List[Property]
    rollout_percentage: Optional[int]
    variant: Optional[str]
    aggregation_group_type_index: Optional[int]  # NEW
    # None = person (hash distinct_id)
    # 0-4 = group (hash that group type's key)

Migration: Existing flags copy their flag-level aggregation_group_type_index to all condition sets.

Evaluation Flow
┌─────────────────────────────────────────────────────────────────┐
│                    Flag Evaluation Request                       │
│  distinct_id: "user-123"                                         │
│  person_properties: { plan: "pro", is_beta_tester: true }        │
│  groups: { company: "acme", project: "proj-456" }                │
│  group_properties: { company: { size: "enterprise" }, ... }      │
└──────────────────────────────┬──────────────────────────────────┘
                               │
                               ▼
┌──────────────────────────────────────────────────────────────────┐
│                    For Each Condition Set                         │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │ Condition Set 1:                                            │ │
│  │   filters: group.company.size = 'enterprise'                │ │
│  │   rollout: 50%                                              │ │
│  │   aggregation: company (index=0)                            │ │
│  │                                                             │ │
│  │   1. Check filters → match!                                 │ │
│  │   2. hash_key = groups[0] = "acme"                          │ │
│  │   3. hash("acme") < 50%? → Yes                              │ │
│  │   4. Return TRUE                                            │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                              OR                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │ Condition Set 2:                                            │ │
│  │   filters: person.is_beta_tester = true                     │ │
│  │   rollout: 75%                                              │ │
│  │   aggregation: person (index=None)                          │ │
│  │                                                             │ │
│  │   1. Check filters → match!                                 │ │
│  │   2. hash_key = distinct_id = "user-123"                    │ │
│  │   3. hash("user-123") < 75%? → ...                          │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
│  First matching condition set wins → return result                │
└──────────────────────────────────────────────────────────────────┘
Example Scenarios

Scenario 1: Specific merchants + specific users

Condition Set 1:
  filters: group.merchant.key IN ['acme', 'beta-corp']
  rollout: 100%
  aggregation: merchant
  
Condition Set 2:
  filters: distinct_id IN ['alice', 'bob']  
  rollout: 100%
  aggregation: person

Result: All users in acme/beta-corp see the flag, plus alice and bob regardless of merchant.

Scenario 2: 50% of enterprise companies OR 75% of beta users

Condition Set 1:
  filters: group.company.size = 'enterprise'
  rollout: 50%
  aggregation: company
  
Condition Set 2:
  filters: person.is_beta_tester = true
  rollout: 75%
  aggregation: person

Result: Users in 50% of enterprise companies see the flag. Additionally, 75% of beta testers see it (even if their company wasn't selected or isn't enterprise).

Scenario 3: Mixed filters within one condition (person + group filters together)

Condition Set 1:
  filters: person.plan = 'pro' AND group.company.size = 'enterprise'
  rollout: 50%
  aggregation: company

Result: 50% of enterprise companies are selected. Within those, only pro users see the flag.

UI Changes

Each condition set shows its own aggregation selector:

┌─────────────────────────────────────────────────────────────────┐
│ Condition Set 1                                                 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Match users where:                                          │ │
│ │   company :: size = 'enterprise'                            │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
│ Roll out to [50%] of [Companies ▼]                              │
│                       ├─ Users                                  │
│                       ├─ Companies                              │
│                       └─ Projects                               │
└─────────────────────────────────────────────────────────────────┘
                              OR
┌─────────────────────────────────────────────────────────────────┐
│ Condition Set 2                                                 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Match users where:                                          │ │
│ │   is_beta_tester = true                                     │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
│ Roll out to [75%] of [Users ▼]                                  │
└─────────────────────────────────────────────────────────────────┘
What We're NOT Building
  • Denormalized group_X_key columns on persons table — Groups are event-contextual, not person-contextual. A person can be in different groups across different sessions, so persisting "membership" doesn't match the data model.

  • New person-group membership tables — Same reason. The SDK already provides group context at evaluation time; we'd be duplicating data that can go stale.

  • Ingestion pipeline changes to track group membership — No membership to track. The group context is ephemeral and provided per-request.

  • Person merge handling for group membership — Since we're not persisting membership, there's nothing to merge.

  • Nested rollout ("50% of groups, then 75% of people within those") — Significantly more complex evaluation logic and confusing UX. Per-condition-set aggregation with OR logic covers the known use cases without this complexity.

Blast Radius

Each condition set has its own blast radius, displayed per-set:

Person aggregation:

SELECT uniqExact(person_id) as affected_users
FROM events
WHERE team_id = :team_id AND [filters]

Display: "~1,234 users"

Group aggregation:

SELECT 
  uniqExact($group_0) as affected_groups,
  uniqExact(person_id) as affected_users  
FROM events
WHERE team_id = :team_id AND [filters]

Display: "~50 companies (~1,234 users)"

Combined display for a flag with multiple condition sets:

Condition Set 1: ~50 companies (~1,234 users)
Condition Set 2: ~500 users
Total reach: ~1,600 users (estimated, may overlap)

Tasks

Schema & Migration
  • Add aggregation_group_type_index to condition set model
  • Migrate existing flags: copy flag-level aggregation to all condition sets
  • Update API serializers
Flag Evaluation (Rust Service)
  • Read aggregation from condition set, not flag
  • Allow conditions to contain both person and group property filters
  • Evaluate group properties against provided group_properties context
  • Use correct hash key per condition set
Frontend
  • Add per-condition-set aggregation selector
  • Allow selecting group properties in conditions regardless of aggregation
  • Update blast radius display per condition set
Blast Radius (HogQL)
  • Update queries to handle mixed property conditions
  • Return per-condition-set estimates
SDK Local Evaluation
  • Update local evaluation to read aggregation per condition set
  • Apply group property filters from provided context
  • Use correct hash key per condition set
Documentation
  • Document per-condition-set aggregation
  • Add examples for common B2B use cases

Open Questions

  1. Validation: If a condition set has group property filters but the SDK call doesn't provide that group type, should we return false (strict) or skip that condition set (permissive)?

  2. Blast radius overlap: When multiple condition sets match overlapping users, should we try to estimate unique reach or just show per-set numbers with an "may overlap" note?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.