# How the PostHog Team Model and Organization Hierarchy Work: Structure, Permissions, and Best Practices

> Understand the PostHog Team model and organization hierarchy. Learn about its parent-child structure, permissions, and best practices for analytics environments.

- Repository: [PostHog/posthog](https://github.com/PostHog/posthog)
- Tags: architecture
- Published: 2026-04-25

---

**The PostHog Team model implements a strict parent-child hierarchy where Organizations own isolated analytics environments (Teams) with optional self-referential nesting for staging/production workflows, enforced through Django foreign keys and role-based access control.**

The **Team model and organization hierarchy** in PostHog define how customer data is isolated and accessed across workspaces. Defined across [`posthog/models/team/team.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/team/team.py) and [`posthog/models/organization.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/organization.py), this architecture separates organizations (billing and user containers) from teams (isolated analytics environments with distinct API tokens), while supporting nested environments and aggressive Redis caching for high-throughput API access.

## Core Database Relationships

PostHog’s hierarchy rests on three critical Django relationships defined in the core models.

**Organization-to-Teams (One-to-Many):**
In [`posthog/models/organization.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/organization.py), the `Organization` model serves as the root container. The `Team` model in [`posthog/models/team/team.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/team/team.py) defines:

```python
organization = models.ForeignKey(
    "posthog.Organization", 
    on_delete=models.CASCADE, 
    related_name="teams"
)

```

This creates a strict ownership model where every team belongs to exactly one organization, accessible via `org.teams.all()`, while organization deletion cascades to all child teams.

**Self-Referential Team Nesting:**
Teams support optional environment hierarchies through a nullable self-reference:

```python
parent_team = models.ForeignKey(
    "posthog.Team", 
    on_delete=models.SET_NULL, 
    related_name="child_teams", 
    null=True
)

```

This enables patterns like `Staging → Production` chains without enforcing strict tree constraints.

**User Association via OrganizationMembership:**
Users link to organizations through `OrganizationMembership` (defined in [`posthog/models/organization.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/organization.py)), which stores role levels (member, admin, owner). Team-level access control extends this via `Team.all_users_with_access()`, aggregating users with direct organization membership, explicit team ACLs, or administrative roles.

## Team Creation and Bootstrap Flow

PostHog provides two primary pathways for team instantiation.

**Organization Bootstrap (Recommended):**
The `OrganizationManager.bootstrap()` method in [`posthog/models/organization.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/organization.py) (lines 99-118) creates an organization, default project, and initial team atomically:

```python
from posthog.models.organization import Organization

org, membership, team = Organization.objects.bootstrap(
    user=request_user,
    team_fields={"name": "Acme Analytics"},
    name="Acme Corp"
)

```

This transaction ensures consistency between billing entities and analytics workspaces.

**Direct Team Creation:**
For additional teams, `TeamManager.create()` in [`posthog/models/team/team.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/team/team.py) (lines 56-73) handles instantiation:

```python
team = Team.objects.create(
    name="New Workspace", 
    organization=existing_org
)

```

The manager automatically provisions a matching `Project` record and initializes default dashboards and session recording playlists.

## Nested Team Hierarchies and Navigation

The `parent_team` field enables environment isolation without separate organization overhead.

Navigate hierarchies using the related_name accessors:

```python

# Navigate upward

parent = team.parent_team  # Returns None or Team instance

# Navigate downward  

children = team.child_teams.all()  # QuerySet of nested teams

# Organization-level aggregation

all_org_teams = organization.teams.select_related("parent_team").all()

```

This structure supports common patterns like separating production event streams from testing environments while maintaining unified organization-level billing and user management.

## Caching and Token Management

PostHog implements aggressive Redis caching to minimize database load on high-traffic API endpoints.

**Token-Based Cache Lookup:**
The `TeamManager.get_team_from_cache_or_token()` method checks `team_in_cache` Redis keys before querying PostgreSQL. Cache population and invalidation occur through Django signals in [`posthog/models/team/team.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/team/team.py) (lines 84-92):

- `put_team_in_cache_on_save` triggers on `post_save`
- `delete_team_in_cache_on_delete` triggers on `post_delete`

**API Token Rotation:**
For security incidents, teams support token regeneration via `reset_token_and_save()`:

```python
team = Team.objects.get(api_token="compromised-token")
team.reset_token_and_save(
    user=request_user, 
    is_impersonated_session=False
)

```

This method invalidates the Redis cache entry and generates a cryptographically secure replacement.

## Access Control Implementation

Permission enforcement operates at two levels.

**Organization-Level Permissions:**
Viewsets inherit from `OrganizationMemberPermissions` and `OrganizationAdminWritePermissions` to enforce role-based access across all organization teams.

**Team-Level Permissions:**
The `TeamAndOrgViewSetMixin` and `AccessControlViewSetMixin` (implemented in [`posthog/api/team.py`](https://github.com/PostHog/posthog/blob/main/posthog/api/team.py)) combine organization-wide membership checks with explicit team ACLs. The `Team.all_users_with_access()` method (lines 107-133 in [`team.py`](https://github.com/PostHog/posthog/blob/main/team.py)) dynamically aggregates authorized users by evaluating:

- Direct organization membership
- Explicit team access grants
- Administrative role elevation

## Practical Implementation Examples

### Bootstrap a New Organization with Default Team

```python
from posthog.models.organization import Organization

org, membership, team = Organization.objects.bootstrap(
    user=request_user,
    team_fields={"name": "Production Environment"},
    name="Acme Corp"
)
print(f"Org: {org.name}, Team Token: {team.api_token}")

```

### Create a Child Team for Staging

```python
from posthog.models.team import Team

production = Team.objects.get(id=42)
staging = Team.objects.create(
    name="Staging",
    organization=production.organization,
    parent_team=production
)

```

### Query All Accessible Teams for a User

```python
accessible_teams = Team.objects.filter(
    organization__memberships__user=request_user
).distinct().select_related("organization")

```

### Rotate API Token After Security Event

```python
team = Team.objects.get(api_token="phc_oldtoken123")
team.reset_token_and_save(
    user=request_user,
    is_impersonated_session=False
)

```

## Summary

- **Organizations** are billing and user containers; **Teams** are isolated analytics environments with unique API tokens.
- The hierarchy uses a **ForeignKey** from `Team` to `Organization` with `related_name="teams"`, and a **self-referential ForeignKey** (`parent_team`) for environment nesting.
- **Creation** typically flows through `Organization.objects.bootstrap()` for initial setup, or `Team.objects.create()` for additional teams.
- **Redis caching** via `get_team_from_cache_or_token()` minimizes database load, with automatic invalidation through Django signals.
- **Access control** combines `OrganizationMembership` roles with `Team.all_users_with_access()` for granular permissions.
- **Token rotation** uses `reset_token_and_save()` to invalidate cached credentials and generate new tokens securely.

## Frequently Asked Questions

### How does team nesting work in PostHog?

Team nesting utilizes the `parent_team` ForeignKey on the `Team` model. A team can optionally specify another team as its parent, creating hierarchical relationships like staging and production environments. This is implemented via `models.SET_NULL` behavior, meaning deleting a parent team nullifies the reference rather than cascading deletion to children. Access child teams through the `child_teams` related manager.

### What is the difference between an Organization and a Team in PostHog?

An **Organization** represents the billing entity and user management container, handling subscriptions and high-level permissions. A **Team** represents an isolated analytics workspace with its own distinct `api_token`, event ingestion pipeline, dashboards, and feature flags. While organizations manage who has access, teams determine where data is stored and how it is queried, with strict data isolation between teams.

### How does PostHog cache Team objects for API performance?

PostHog caches active `Team` records in Redis using the `get_team_in_cache` and `set_team_in_cache` utility functions from [`posthog/models/team/team_caching.py`](https://github.com/PostHog/posthog/blob/main/posthog/models/team/team_caching.py). The `TeamManager.get_team_from_cache_or_token()` method attempts Redis lookup by `api_token` before falling back to PostgreSQL. Django signal handlers `put_team_in_cache_on_save` and `delete_team_in_cache_on_delete` ensure cache consistency automatically on model updates.

### Can a user belong to multiple teams in different organizations?

Yes. Users associate with organizations through `OrganizationMembership` records, and can hold memberships in multiple organizations simultaneously. Within each organization, they inherit access to teams based on organization-level roles or explicit team grants. The `Team.all_users_with_access()` method aggregates these permission sources to determine effective access rights across the hierarchy.