<ng-container> is a logical grouping element Angular understands at compile time but never actually renders into the DOM — no wrapping <div> or <span> appears in the final HTML. It exists specifically for cases where you need a structural directive (*ngIf, *ngFor) without a real element to attach it to.
The problem it solves
<!-- Adds an unwanted <div> to the DOM just to hold *ngIf -->
<div *ngIf="showDetails">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
</div>
<!-- No extra element in the rendered output -->
<ng-container *ngIf="showDetails">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
</ng-container>
The difference matters when the extra wrapping element would break CSS selectors relying on direct parent-child relationships (like flex or grid layouts), or when it would be semantically wrong (wrapping <li> elements in a <div> inside a <ul>, for example, which is invalid HTML).
Combining multiple structural directives
Angular doesn't allow two structural directives on the same element (*ngIf and *ngFor can't both go on the same tag) — ng-container is the standard way around that, nesting one inside the other without adding real elements for either:
<ng-container *ngIf="items.length > 0">
<ng-container *ngFor="let item of items">
<li>{{ item.name }}</li>
</ng-container>
</ng-container>
With ng-template and else
<ng-container *ngIf="isLoggedIn; else guestView">
<p>Welcome back!</p>
</ng-container>
<ng-template #guestView>
<p>Please log in.</p>
</ng-template>
Modern alternative: the @if/@for control-flow syntax
Angular 17+ introduced a built-in template control-flow syntax (@if, @for) that doesn't require ng-container for most of these cases at all, since @if/@for blocks aren't tied to a host element the way structural directives are. ng-container is still relevant for projects on the older directive-based syntax, or for edge cases the new control-flow syntax doesn't cover directly (like ngSwitch grouping).