# l4 tags apply

> Create or replace a virtual tag key from a YAML file. Prints the difference against the saved key first, then reprocesses the key's costs from its backfill month.

Source: https://docs.levelfour.ai/cli/commands/tags/apply


`l4 tags apply` makes a virtual key in LevelFour match a YAML file. When no virtual key has the file's `name`, it creates one. When one does, the file replaces that key's whole definition. The command prints the difference before it sends anything.

```bash
l4 tags apply -f <file> [flags]
```

> **Note**
>
> Nothing changes in your cloud account. A virtual key changes how LevelFour labels your costs, and your provider tags stay as they are.

## What it changes

The command reads `GET /api/v1/tags/keys` to find a virtual key named like the file, then:

* with no such key, posts the file to `/api/v1/tags/virtual` and creates one
* with one whose rules the file changes, sends `PUT /api/v1/tags/virtual/{key_id}` with the whole file, and the key's `rules_version` as `expected_version`
* with one whose rules the file leaves alone, sends `PATCH /api/v1/tags/virtual/{key_id}` with the settings that moved, so editing a description does not reprocess a year of spend

The file replaces the definition, so a config you removed from the file is gone from the key once the command finishes. A change to the configs, the collapsed keys, `can_override` or `effective_from` starts a job that reprocesses the key from `effective_from` to the current month, and the key reads `reprocessing` until it ends.

> **Note**
>
> `apply` finds the key by name. Change `name` in the file and the next run creates a second key, leaving the first in place. To rename a key, send its new name to `PATCH /api/v1/tags/virtual/{key_id}`, then change the file to match.

## File format

A tag file describes one virtual key. [`l4 tags preview`](/cli/commands/tags/preview) reads the same format.

```yaml title="teams.yaml"
name: Teams
description: Owning team across every cloud.
effective_from: "2026-06"        # optional, YYYY-MM; omitted = current month
can_override: false               # optional
collapsed_keys:                   # optional, in priority order
  - source: {origin: provider, key: owner}
    providers: [aws]              # optional scope
    prefix: ""                    # optional value prefix
configs:                          # in priority order, first match wins
  - value: data
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: service, operator: is, values: [Amazon Redshift, BigQuery]}
  - value: team-a
    rules:
      - providers: [aws, gcp]
        where:
          - {dimension: tag, key: team, operator: flexible_match, values: [Team A]}
  - split:
      - {value: infra, pct: 60}
      - {value: mobile, pct: 40}
    rules:
      - providers: [aws]
        where:
          - {dimension: account, operator: is, values: ["999900001111"]}
  - cost_based:
      source: {origin: provider, key: team}
      input:
        - providers: [aws]
          where:
            - {dimension: charge_type, operator: is, values: [Usage]}
    rules:
      - providers: [aws]
        where:
          - {dimension: service, operator: starts_with, values: [AWS Support]}
    time_frame: {start: "2026-07-01"}   # optional
```

| Field                        | Meaning                                                                                                                        |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `name`                       | Required. The key's name, and how `apply` finds the saved key                                                                  |
| `description`                | Free text                                                                                                                      |
| `effective_from`             | First billing month the key covers, as a quoted `YYYY-MM`. Omitted means the current month                                     |
| `can_override`               | On a key named after a provider key, whether configs replace the provider value. Defaults to `false`                           |
| `collapsed_keys[].source`    | The key to copy from: `origin` (`provider` or `virtual`) and `key`                                                             |
| `collapsed_keys[].providers` | Limits the source to these providers                                                                                           |
| `collapsed_keys[].prefix`    | Text put in front of the copied value                                                                                          |
| `configs[]`                  | Required. Each config carries one of `value`, `split` or `cost_based`, and only one, plus `rules` and an optional `time_frame` |
| `configs[].value`            | One value for every line the config matches                                                                                    |
| `configs[].split[]`          | `value` and `pct` pairs that add up to 100                                                                                     |
| `configs[].cost_based`       | `source`, the key whose values share the cost, and `input`, rules that select the cost to measure the shares on                |
| `configs[].rules[]`          | `providers` (`aws`, `gcp`, `azure`, `digitalocean`) and `where`. One matching rule is enough                                   |
| `configs[].rules[].where[]`  | Conditions, all of which must hold: `dimension`, `key`, `operator` (default `is`) and `values`                                 |
| `configs[].time_frame`       | `start` and `end` dates as `YYYY-MM-DD`. Either one can be left out                                                            |

`l4` rejects a field it does not recognise, so a typo such as `condition:` for `where:` fails the command instead of dropping the rule. [Virtual tags](/guides/virtual-tags) has the dimensions, the operators, and a worked example for each kind of config.

## Flags

| Flag        | Description                                                                               |
| ----------- | ----------------------------------------------------------------------------------------- |
| `-f <file>` | Tag file to apply. Required                                                               |
| `--dry-run` | Print the difference and send nothing. Exits `2` when the file differs from the saved key |
| `--yes`     | Skip the confirmation prompt                                                              |

Global flags (`--json`, `--jq`, `--template`, `--quiet`, `--token`, `--api`) apply. See [CLI overview](/cli#global-flags).

## Dry run

`--dry-run` reads the saved key, prints the difference and stops. It only reads, so a read-only key can run it. The exit code says what it found:

| Exit       | Meaning                                                    |
| ---------- | ---------------------------------------------------------- |
| `0`        | The saved key matches the file                             |
| `2`        | Applying would change the key, or no key has that name yet |
| `1` or `4` | An error, as in [Errors](#errors)                          |

Run it on each pull request that touches a tag file, and the pipeline fails whenever the saved key and the file disagree:

```bash
l4 tags apply -f tags/teams.yaml --dry-run --quiet
```

> **Success**
>
> A dry run that exits `2` is the check doing its job: it found a difference to apply. Exit `1` is the one that means something broke.

## Confirmation

By default, the command prints the difference, then asks for confirmation with a `[y/N]` prompt. Anything other than `y` or `yes` aborts, prints `Aborted.`, and exits `0` without sending the change. Pass `--yes` to skip the prompt in an interactive shell. When the file matches the saved key, there is nothing to confirm and nothing is sent.

> **Warning**
>
> When stdout is not a TTY, so in a pipeline or a CI job, the prompt never appears and the change goes through. A job that runs `apply` without `--dry-run` rewrites the key and reprocesses its costs from `effective_from` with no one asked to confirm.

## Requires a read-write key

Writes need the `org:tags:write` permission, which a read-write key carries, and `l4 auth login` mints a read-only key. A read-only key fails with exit `1` and `permission denied: this API key lacks permission for this operation`. See [CLI authentication](/cli/authentication#browser-login-mints-a-read-only-key) for how to create a read-write key.

## Re-running it

A second run with the same file finds no difference and sends nothing. The file wins over the dashboard: a run after someone edited the key there replaces their edit, and the printed difference shows what it undoes.

If someone changes the key between the command's read and its write, the API answers `409` `version_conflict` and saves nothing. Run `apply` again to diff against the new version.

## Examples

```bash
l4 tags apply -f teams.yaml
l4 tags apply -f teams.yaml --dry-run
l4 tags apply -f teams.yaml --yes --json
LEVELFOUR_TOKEN=$L4_RW_KEY l4 tags apply -f teams.yaml --yes --quiet
```

Apply several files in order, with the key that another one reads going first:

```bash
for f in tags/applications.yaml tags/business-units.yaml; do
  l4 tags apply -f "$f" --yes || exit 1
done
```

## Output

Default output is the difference, then a success line naming the key and the reprocessing job. With `--json` the whole API envelope is printed. The fields it carries:

```text
.success
.data.key                    # the key as l4 tags show --json prints it
.data.key.id
.data.key.rules_version
.data.key.status             # "reprocessing" while the job runs
.data.job                    # null when nothing needs reprocessing
.data.job.id
.data.job.status             # "queued" or "running" right after a change
.data.job.from_period
.data.job.to_period
.timestamp
```

> **Success**
>
> `.data.job.status` at `queued` or `running` is the expected result after a rules change: the definition is saved and LevelFour is reprocessing costs. Follow the job with [`l4 tags show`](/cli/commands/tags/show#reprocessing-jobs). A run that only changed settings sends `PATCH` and answers with `.data.job` at `null`, which is also correct.

`--dry-run --json` prints the plan instead of an API envelope: `action` (`create`, `update` or `none`), `key_id` on an existing key, and `definition`, the body the real run would send.

> **Warning**
>
> `apply` asks for confirmation the same way `delete` does. Outside a terminal, without `--yes`, it exits `1` with `applying &lt;key&gt; outside a terminal needs --yes` and sends nothing. A pipeline that means to apply passes `--yes`; one that means to check passes `--dry-run`.

## Validation problems

A file that breaks a rule fails with `422` `validation_failed`, and the error lists every problem at once. Each problem carries a `code` and, where one applies, the position of the config, rule, condition, split or collapsed key at fault. The API counts those from `0` and the CLI prints them from `1`, matching how [`l4 tags show`](/cli/commands/tags/show) numbers configs. The codes a file author meets most:

| Code                                 | Cause                                                                                  |
| ------------------------------------ | -------------------------------------------------------------------------------------- |
| `rule_no_provider`                   | A rule lists no provider                                                               |
| `rule_no_condition_value`            | A condition has no `values`                                                            |
| `key_required`                       | A `tag` or `virtual_tag` condition has no `key`                                        |
| `dimension_unsupported_for_provider` | The dimension does not exist on one of the rule's providers, such as `category` on GCP |
| `operator_unsupported`               | The operator does not apply to the dimension                                           |
| `split_not_100`                      | A split's percentages do not add up to 100                                             |
| `split_value_duplicate`              | A value appears twice in one split                                                     |
| `time_frame_invalid`                 | The time frame ends before it starts                                                   |
| `effective_from_invalid`             | `effective_from` is not a `YYYY-MM` month                                              |
| `effective_from_unavailable`         | `effective_from` is further back than the backfill limit                               |
| `collapsed_source_unknown`           | A collapsed key names a key that does not exist                                        |
| `cost_source_unknown`                | A cost-based `source` names a key that does not exist                                  |
| `nested_key_unknown`                 | A `virtual_tag` condition names a key that does not exist                              |
| `nested_cycle`                       | A chain of keys that read each other loops back to its start                           |
| `nesting_too_deep`                   | A chain of nested keys goes past the depth limit                                       |
| `nested_allocation_unsupported`      | A `virtual_tag` condition reads a key that holds split or cost-based configs           |
| `limit_exceeded`                     | The key goes past one of the [limits](/guides/virtual-tags#limits)                     |

The [Tags API reference](/api-reference/tags) lists every code.

## Errors

| Exit | Symptom                                          | Cause                                                                   |
| ---- | ------------------------------------------------ | ----------------------------------------------------------------------- |
| `4`  | Authentication required                          | No token resolvable; run `l4 auth login` or set `LEVELFOUR_TOKEN`       |
| `2`  | `--dry-run` found a difference                   | The file and the saved key differ. Nothing was sent                     |
| `1`  | The file does not parse, or has an unknown field | Fix the file against [File format](#file-format)                        |
| `1`  | `401` authentication failed                      | Token revoked, expired, or from the wrong environment                   |
| `1`  | `403` permission denied                          | The key is read-only, or your role lacks `org:tags:write`               |
| `1`  | `409` `version_conflict`                         | The key changed between the read and the write. Run `apply` again       |
| `1`  | `422` `validation_failed`                        | The file breaks a rule. See [Validation problems](#validation-problems) |

## Related

* [`l4 tags preview`](/cli/commands/tags/preview): try the file before you apply it
* [`l4 tags show`](/cli/commands/tags/show): the saved key and its reprocessing job
* [`l4 tags delete`](/cli/commands/tags/delete): remove a key
* [Virtual tags](/guides/virtual-tags): worked examples for every kind of config
* [CLI authentication](/cli/authentication): read versus read-write keys
