brew update vs brew upgrade: What Actually Happens
`brew update` fetches the latest version of the Homebrew formulae repository from GitHub. It does not touch any installed package. It is equivalent to `apt-get update` on Debian - it only refreshes the local metadata. Under the hood it runs a `git fetch` and `git merge` against the Homebrew/homebrew-core tap, plus any third-party taps you have added.
`brew upgrade` reads that refreshed metadata and upgrades installed formulae to the newest available version. Without arguments it upgrades everything outdated. That is rarely what you want on a development machine with pinned toolchain versions.
The correct workflow before any upgrade session is always:
``` brew update && brew outdated ```
Review the `outdated` list before you commit to upgrading anything. The output format is `formula (installed_version) < new_version`, for example `python@3.12 (3.12.7) < 3.12.9`. That gives you an explicit diff to reason about before running the upgrade.
brew update
brew outdated
brew outdated --cask
Upgrading Specific Formulae Instead of Everything
Upgrading a single formula is the safest default for workstations running active projects. Pass the formula name as an argument:
``` brew upgrade git ```
To upgrade multiple specific packages:
``` brew upgrade git curl wget ```
If you want to see what a formula upgrade will pull in before committing, use `--dry-run`:
``` brew upgrade --dry-run git ```
In our testing on Homebrew 4.4, `--dry-run` prints the exact version transition and any dependency changes without modifying anything on disk. Use it whenever you are unsure about transitive dependency upgrades. For example, upgrading `ffmpeg` pulls in `libaom`, `libvpx`, `x264`, and several other libraries - seeing that list before it runs is useful.
brew upgrade --dry-run ffmpeg
Pinning Formulae to Prevent Accidental Upgrades
`brew pin` marks a formula so that `brew upgrade` skips it entirely, even when you run `brew upgrade` with no arguments. This is the correct tool for locking toolchain versions on shared developer machines.
To pin a formula:
``` brew pin python@3.11 ```
To see all pinned formulae:
``` brew list --pinned ```
To release a pin when you are ready to upgrade:
``` brew unpin python@3.11 brew upgrade python@3.11 ```
Pinning is stored as a symlink in `/usr/local/var/homebrew/pinned/` (Intel) or `/opt/homebrew/var/homebrew/pinned/` (Apple Silicon). On Apple Silicon machines, which have been the default since the M1 series, the Homebrew prefix is `/opt/homebrew`. If you manage multiple machines or onboard engineers regularly, document which formulae are pinned and why - it prevents hours of debugging when someone runs `brew upgrade` on a fresh clone.
Note that `brew outdated` will still show pinned formulae as outdated. They just will not be touched by a bare `brew upgrade`. The `brew outdated` output flags pinned packages with `(pinned at X.Y.Z)` so they are easy to identify.
brew pin python@3.11
brew list --pinned
brew unpin python@3.11
Handling Cask Upgrades
Homebrew Cask manages GUI applications like VS Code, Docker Desktop, and Firefox. Cask upgrades are handled by the same `brew upgrade` command with the `--cask` flag or by passing the cask name directly.
Check which casks are outdated:
``` brew outdated --cask ```
Upgrade all outdated casks:
``` brew upgrade --cask ```
Upgrade a specific cask:
``` brew upgrade --cask visual-studio-code ```
Cask upgrades often require elevated privileges because they write to `/Applications`. If the cask does not declare `auto_updates true`, Homebrew will upgrade it. Applications that manage their own updates (like Chrome) declare `auto_updates true` in their cask definition, and `brew outdated --cask` will not report them unless you pass `--greedy`:
``` brew outdated --cask --greedy ```
On our test server running macOS Sequoia 15.3, `--greedy` caught 11 applications that ship their own updaters but still benefit from a Homebrew-managed baseline. Use `--greedy` in scheduled jobs where you want a complete inventory, but avoid it in interactive sessions unless you intend to upgrade everything including self-updating apps.
brew outdated --cask --greedy
brew upgrade --cask --greedy
Cleaning Up After Upgrades
Every upgrade keeps the previous version of the formula on disk in the Homebrew cellar. On an active workstation this accumulates fast. `brew cleanup` removes all old versions, leftover downloads, and stale lockfiles.
Run a dry-run first to see what would be removed:
``` brew cleanup --dry-run ```
Then execute:
``` brew cleanup ```
To remove old versions of a specific formula only:
``` brew cleanup git ```
By default, Homebrew keeps versions installed in the last 120 days. You can override this with the `HOMEBREW_CLEANUP_MAX_AGE_DAYS` environment variable:
``` export HOMEBREW_CLEANUP_MAX_AGE_DAYS=30 brew cleanup ```
On our test machine after six months of active use without cleanup, `brew cleanup --dry-run` reported 4.3 GB of reclaimable space. That number is not unusual for machines running multiple versions of compiled languages like Go, Rust, or Python. Add `brew cleanup` to any upgrade automation you build.
brew cleanup --dry-run
export HOMEBREW_CLEANUP_MAX_AGE_DAYS=30
brew cleanup
Diagnosing Problems with brew doctor
`brew doctor` runs a series of checks against your Homebrew installation and reports warnings and errors. Run it before and after any major upgrade session.
``` brew doctor ```
Common issues it catches:
- Stale symlinks in `/opt/homebrew/bin` - Keg-only formulae that have been manually linked (causing PATH conflicts) - Outdated Xcode Command Line Tools - Conflicting files from MacPorts or manually installed packages
If `brew doctor` reports a broken link, fix it with:
```
brew missing
brew install
`brew missing` identifies formulae whose dependencies are not installed. Run it after any partial upgrade or failed install to see what is inconsistent. In our experience, about 80% of post-upgrade Homebrew problems are diagnosed and resolved by running `brew doctor` and `brew missing` in sequence.
For dependency graph issues, `brew deps --tree
``` brew deps --tree openssl@3 ```
That is useful when a formula upgrade fails because a dependency is pinned to an incompatible version.
brew doctor
brew missing
brew deps --tree openssl@3
Rolling Back a Bad Upgrade
If a formula upgrade breaks something, `brew switch` was the historical tool but it was removed in Homebrew 4.x. The current approach is `brew extract` combined with installing a specific version, or using the `homebrew-versions` tap if one exists for your formula.
For formulae that ship versioned variants (like `python@3.11`, `python@3.12`), install the older versioned formula directly:
``` brew install python@3.11 brew unlink python@3.12 brew link python@3.11 ```
For formulae without versioned taps, you need to extract the old version from the homebrew-core history:
``` brew extract --version=2.40.0 git homebrew/core brew install git@2.40.0 ```
The extract command creates a new formula in a local tap. This is more involved than `apt-get install git=2.40.0-1` on Debian, but it gives you the same result. After extracting and installing, pin the formula immediately to prevent the next `brew upgrade` from overwriting it:
``` brew pin git@2.40.0 ```
To check what version of a formula was current on a specific date, use `brew log`:
``` brew log --oneline git | head -20 ```
This shows the commit history for the git formula in homebrew-core, which maps directly to version changes.
brew extract --version=2.40.0 git homebrew/core
brew install git@2.40.0
brew pin git@2.40.0
Automating brew update and brew upgrade
For DevOps teams managing fleets of macOS machines - CI runners, developer workstations provisioned via MDM - automating Homebrew updates is standard practice. The simplest approach is a launchd plist that runs on a schedule.
Create `/Library/LaunchDaemons/org.myunix.brew-upgrade.plist` with:
```xml
Load it with:
``` sudo launchctl load /Library/LaunchDaemons/org.myunix.brew-upgrade.plist ```
For teams using Ansible, Chef, or script-based provisioning, Homebrew provides the `HOMEBREW_NO_AUTO_UPDATE` environment variable to suppress the automatic update that runs before every `brew install`. In automation scripts, always run `brew update` explicitly and then disable the automatic update:
``` export HOMEBREW_NO_AUTO_UPDATE=1 brew update brew upgrade --formula brew upgrade --cask brew cleanup ```
This prevents the double-update problem where `brew update` runs once explicitly and then again automatically when `brew install` is called. If you are building DevOps automation pipelines that manage macOS runners, tools like taskbotshub.ai can orchestrate Homebrew update jobs across machines alongside your Linux infrastructure, keeping macOS nodes in sync without custom shell glue.
export HOMEBREW_NO_AUTO_UPDATE=1
brew update
brew upgrade --formula
brew upgrade --cask
brew cleanup
Useful Environment Variables for Controlling Homebrew Behavior
Homebrew's behavior is configurable through environment variables, which is more portable than modifying Homebrew's own config files. These are the ones that matter for update and upgrade workflows:
`HOMEBREW_NO_AUTO_UPDATE=1` - Disables the automatic `brew update` that runs before every install, upgrade, or other command. Essential in CI.
`HOMEBREW_NO_INSTALL_CLEANUP=1` - Prevents automatic cleanup after installs. Useful if you want manual control over when old versions are removed.
`HOMEBREW_CLEANUP_MAX_AGE_DAYS=N` - Sets the age threshold for cleanup. Default is 120.
`HOMEBREW_CURL_RETRIES=N` - Number of times to retry failed downloads. Default is 3. Increase to 5 or more on unreliable networks.
`HOMEBREW_NO_ANALYTICS=1` - Disables the anonymous analytics reporting to Homebrew's Google Analytics endpoint. Standard on enterprise machines.
`HOMEBREW_GITHUB_API_TOKEN` - GitHub API token to avoid rate limiting when Homebrew makes API calls. On CI machines running frequent `brew update` calls, you will hit the 60 requests/hour unauthenticated limit. Set this to a personal access token with no scopes:
``` export HOMEBREW_GITHUB_API_TOKEN=ghp_yourtokenhere brew update ```
Add these exports to `/etc/zshenv` for system-wide effect on all users, or to `~/.zshenv` for per-user configuration. Using `.zshrc` is less reliable because it is only sourced for interactive shells, which means automation scripts running via launchd will miss them.
export HOMEBREW_NO_AUTO_UPDATE=1
export HOMEBREW_NO_ANALYTICS=1
export HOMEBREW_CLEANUP_MAX_AGE_DAYS=30
export HOMEBREW_GITHUB_API_TOKEN=ghp_yourtokenhere
Managing Multiple Taps During Updates
`brew update` updates all taps, not just homebrew-core. If you have added third-party taps, they are all fetched during the update. To see which taps are installed:
``` brew tap ```
Typical output on a developer machine might include:
``` homebrew/core homebrew/cask homebrew/cask-fonts homebrew/services mongodbinc/brew heroku/brew ```
Each of these is a Git repository under `$(brew --repository)/Library/Taps/`. If a tap's upstream is slow or unreliable, it will slow down every `brew update` call. Remove unused taps:
``` brew untap heroku/brew ```
If you maintain internal formulae for your organization - private tools, internal services, or custom builds - you manage them as a private tap. When naming that tap or the GitHub repository that backs it, keep the name short and memorable. Services like nicename.me can help you find an available domain name that matches your tap name, useful if you also want to host internal Homebrew tap documentation publicly.
To update a specific tap without updating all others:
``` brew tap --repair homebrew/core ```
The `--repair` flag re-creates symlinks for all formulae in the tap. Use it after a git conflict or a corrupted tap state.
brew tap
brew untap heroku/brew
brew tap --repair homebrew/core
Checking What Changed After an Upgrade
After running `brew upgrade`, you should know exactly what changed. `brew log` shows the commit history for a formula, which maps to version and build changes:
``` brew log git ```
To compare what was installed before and after, capture `brew list --versions` before the upgrade:
``` brew list --versions > ~/brew-before.txt brew upgrade brew list --versions > ~/brew-after.txt diff ~/brew-before.txt ~/brew-after.txt ```
This gives you a precise diff of every version change. On our test machine after a full `brew upgrade` on a six-month-old install, the diff showed 34 formula version changes, including openssl@3 moving from 3.3.1 to 3.4.0 and curl moving from 8.9.1 to 8.11.0. Those are the kinds of changes worth logging in a team changelog or a provisioning audit trail.
For casks, use `brew list --cask --versions` in the same pattern.
``` brew list --cask --versions > ~/cask-before.txt brew upgrade --cask brew list --cask --versions > ~/cask-after.txt diff ~/cask-before.txt ~/cask-after.txt ```
brew list --versions > ~/brew-before.txt
brew upgrade
brew list --versions > ~/brew-after.txt
diff ~/brew-before.txt ~/brew-after.txt