Labels and Milestones
Labels and milestones are GitHub’s built-in tools for organizing issues and pull requests. A label is a small colored tag you attach to an issue or PR to categorize it — bug, enhancement, priority: high. A milestone groups a set of issues and PRs around a shared goal, usually a release, with an optional due date and a progress bar. Together they turn a flat list of issues into something you can triage, filter, and track toward a deadline — without needing a separate project management tool.
Overview / How it works
Labels and milestones are worth understanding clearly because, unlike almost everything else in this course, they are not part of Git’s object model. A commit is a content-addressed object containing a pointer to a tree, a pointer to its parent commit(s), an author, and a message; a branch is just a movable pointer to a commit. None of that is true for labels or milestones. They are rows in GitHub’s own database, associated with an issue or pull request number in a specific repository. They live entirely on GitHub’s servers — git clone does not bring them down, and there is no local Git command that reads or writes them. That is why every example below uses either the GitHub web UI or the gh command-line tool (GitHub’s official CLI, a separate program from git itself), not plain Git.
A label has three properties: a name, a color (a 6-digit hex value), and an optional description. Labels are repo-scoped — created once per repository — and an issue or PR can carry any number of them at once (a many-to-many relationship). Every new GitHub repository ships with a default set:
| Label | Typical use |
|---|---|
bug |
Something isn’t working |
documentation |
Improvements or additions to docs |
duplicate |
This issue or PR already exists |
enhancement |
New feature or request |
good first issue |
Good for newcomers |
help wanted |
Extra attention is needed |
question |
Further information is requested |
wontfix |
This will not be worked on |
A milestone has a title, an optional due date, an optional description, and a state (open or closed). Unlike labels, an issue or PR can belong to at most one milestone at a time — it’s a one-to-one relationship, closer to a folder than a tag. GitHub automatically computes a progress bar (percentage of the milestone’s issues that are closed) but it does not automatically close the milestone itself when every issue in it is closed; you have to close it explicitly. Milestones are intentionally lightweight — they don’t have columns, swimlanes, or automation rules. If you need a Kanban-style board with custom workflow states, that’s what GitHub Projects (a separate feature) is for; a milestone is best thought of as “everything targeted at this release,” not a full project board.
Syntax
Labels and milestones can be managed from the web UI (a repo’s Issues tab has Labels and Milestones sub-pages) or from the terminal with gh, GitHub’s official CLI. The general forms:
gh label list [flags]
gh label create <name> --color <hex> --description <text>
gh label edit <name> [flags]
gh label delete <name> [--yes]
gh issue edit <number> --add-label <name> --milestone <title>
gh issue list --label <name> --milestone <title>
There is no dedicated gh milestone command family, so milestones themselves (creating, closing, setting a due date) are managed either in the web UI or with gh api, which sends raw requests to GitHub’s REST API. Key flags:
| Flag | Meaning |
|---|---|
--color |
6-digit hex color for a label, no leading # |
--description |
Short text shown next to a label in the UI |
--add-label / --remove-label |
Attach or remove one or more labels on an issue/PR |
--milestone |
Assign an issue/PR to a milestone by its title |
-f |
(with gh api) sets a request field, e.g. title, due_on, state |
Examples
Example 1: creating a priority-labeling scheme
A repository called midnightco/storefront only has the GitHub defaults. The team wants a consistent priority scheme instead of vague labels like urgent.
gh label create "priority: high" --color "B60205" --description "Needs immediate attention"
gh label create "priority: medium" --color "FBCA04" --description "Should be addressed soon"
gh label create "priority: low" --color "0E8A16" --description "Nice to have, no rush"
Output:
✓ Label "priority: high" created in midnightco/storefront
✓ Label "priority: medium" created in midnightco/storefront
✓ Label "priority: low" created in midnightco/storefront
Each command creates one label. The color is a plain hex code without a #; GitHub renders it as a colored pill next to every issue that carries the label. Because these labels share the priority: prefix, they sort together in the label picker and read clearly at a glance.
Example 2: creating a milestone and assigning issues to it
The team is planning a release. Milestones aren’t exposed through a dedicated gh subcommand, so they’re created with gh api, which talks directly to GitHub’s REST API:
gh api repos/midnightco/storefront/milestones -f title="v1.0 Launch" -f due_on="2026-09-15T00:00:00Z" -f description="Initial public release"
gh issue edit 42 --milestone "v1.0 Launch"
gh issue edit 57 --milestone "v1.0 Launch"
Output:
{
"id": 9384712,
"number": 3,
"title": "v1.0 Launch",
"state": "open",
"due_on": "2026-09-15T00:00:00Z"
}
✓ Edited issue #42
✓ Edited issue #57
The gh api call creates a milestone and returns the created object as JSON, including a milestone number (3 here) that GitHub assigns internally. The two gh issue edit calls then attach issues #42 and #57 to that milestone by its title. Note that assigning a milestone doesn’t remove or conflict with labels — an issue can be priority: high, bug, and in the v1.0 Launch milestone all at once.
Example 3: filtering and automating with labels
To see exactly what’s left before the release ships, combine a label filter with a milestone filter:
gh issue list --label "bug" --milestone "v1.0 Launch" --state open
Output:
Showing 2 of 2 open issues in midnightco/storefront that match your search
#42 Checkout button misaligned on mobile bug, priority: high
#61 Cart total off by rounding error bug, priority: medium
The same filter typed into the search box on the web UI’s Issues tab looks like is:issue is:open label:bug milestone:"v1.0 Launch". Many teams also automate labeling with a GitHub Actions workflow so pull requests get labeled by the files they touch, instead of relying on someone remembering to do it by hand:
name: Label PRs by path
on:
pull_request_target:
types: [opened, synchronize]
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/labeler@v5
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
This workflow runs the official actions/labeler action on every pull request, matching changed file paths against rules in a .github/labeler.yml file (for example, anything under docs/ gets the documentation label automatically).
How it works step by step
When you run gh label create or assign a milestone through the UI, no Git object is created, no commit is made, and your local .git directory doesn’t change at all. Instead:
ghsends an authenticated HTTPS request to GitHub’s REST API (the same API that powers the website).- GitHub stores the label or milestone as a row tied to the repository’s ID in its own database — not inside the Git repository’s object store.
- When you attach a label to an issue, GitHub records a join between the issue’s number and the label’s ID. Attaching a milestone sets a single
milestone_idfield on that issue or PR. - The milestone’s progress bar is computed live: GitHub counts
closed issues / total issuesassigned to that milestone every time it’s rendered — it isn’t stored as a static number. - Closing every issue in a milestone changes that percentage to 100%, but the milestone’s own
statefield staysopenuntil someone explicitly closes it (via the UI’s “Close milestone” button orgh api -X PATCH repos/:owner/:repo/milestones/3 -f state=closed).
Common Mistakes
Mistake 1: label sprawl from no naming convention. A repo accumulates bug, Bug, bugs, and defect as four separate labels because different contributors typed whatever felt natural at the time. This fragments your filters — searching label:bug misses issues tagged Bug. Fix it by settling on one scheme with prefixes (type:, priority:, status:) and merging duplicates by relabeling issues before deleting the redundant label.
Mistake 2: assuming a milestone closes itself.
# All 12 issues in "v1.0 Launch" are closed, but the milestone still shows as open
gh issue list --milestone "v1.0 Launch" --state open
# (empty result, yet the milestone page still says "Open")
GitHub never closes a milestone automatically, even at 100% progress. You have to close it explicitly — from the Milestones page in the UI, or with gh api -X PATCH repos/:owner/:repo/milestones/3 -f state=closed. Leaving stale open milestones around makes the milestone list misleading for the next person who looks at it.
Mistake 3: deleting a heavily-used label without checking.
gh label delete bug --yes
This silently strips the bug label from every issue and PR that had it, with no confirmation of how many that affects and no way to undo it through Git (again, this isn’t tracked in your repository’s history at all). Before deleting a label, check its usage count on the Labels page or with gh issue list --label bug --state all, and rename instead of delete-and-recreate if you just want to fix a typo (gh label edit preserves existing assignments; delete-then-create does not).
Mistake 4: treating a milestone as a full project board. Milestones only give you a title, a due date, and a flat percentage — there’s no way to represent “in review” vs “blocked” vs “in progress” within one. Teams that try to force that nuance into milestone descriptions end up fighting the tool. If you need custom workflow columns, use GitHub Projects instead and keep the milestone for its one job: what’s targeted at this release.
Best Practices
- Adopt a small, prefixed label vocabulary (
type: bug,priority: high,status: blocked) instead of letting labels grow organically. - Write a short
--descriptionfor every label — it removes ambiguity for new contributors picking labels on their own issues. - Give every milestone a realistic
due_ondate and revisit it weekly; a milestone with no date is just an unordered folder. - Keep milestones scoped to a single release or sprint, not “someday” — a milestone that never closes stops being useful as a signal.
- Use combined filters (
label:+milestone:) in saved searches to build a live view of “what’s left before this ships.” - Automate labeling with an Action like
actions/labelerfor anything that can be inferred from changed file paths, so labels don’t depend on someone remembering. - Before deleting a label, check how many open and closed issues use it — rename with
gh label editif you’re just fixing wording.
Practice Exercises
- In a repo you control, create three labels following a
type:prefix convention (for exampletype: bug,type: feature,type: chore) and apply them across five existing or new issues. - Create a milestone named
v1.0 Launchwith a due date two weeks from today, assign four issues to it, close two of them, and check what percentage the milestone’s progress bar reports. - Using
gh issue list, write a single command that shows only open issues labeledtype: bugthat also belong to thev1.0 Launchmilestone, then find the equivalent search string in the web UI’s issue search box.
Summary
- Labels are colored, repo-scoped tags (name + color + optional description); an issue or PR can carry many at once.
- Milestones group issues and PRs toward one goal (usually a release) with a due date and a live-computed progress bar; an issue can belong to only one milestone at a time.
- Neither lives in Git’s object model — they’re GitHub database records tied to issue/PR numbers, managed via the web UI or the
ghCLI, not local Git commands. - Milestones never auto-close, even at 100% progress — you must close them explicitly.
- A consistent label naming convention and realistic milestone due dates are what make these tools useful at scale; sprawl and stale open milestones are the most common failure modes.
