Temperature-based fan control for Apple Silicon Macs: a small C program that reads the temperature sensors and writes fan targets straight to the SMC. No app, no kernel extension.
Requires an Apple Silicon Mac with macOS 14 or later. In Terminal:
curl -fsSL https://github.com/itswl/fanctl/releases/latest/download/install.sh | shor, with wget:
wget -qO- https://github.com/itswl/fanctl/releases/latest/download/install.sh | shThe installer is a single file that carries the prebuilt binary; nothing is cloned or compiled.
It asks for your password, installs fanctl, the presets, the config (the balanced preset)
and a LaunchDaemon, starts it and prints the status. Run the same command again to upgrade; your
config and autostart setting are kept. If you'd rather read it first, download install.sh and run sh install.sh.
To start with another preset (quiet, balanced, cool, lap or full), add -s -- --preset NAME;
the old config is kept as fancontrol.conf.bak:
curl -fsSL https://github.com/itswl/fanctl/releases/latest/download/install.sh | sh -s -- --preset lapUninstall with sudo /usr/local/share/fanctl/uninstall.sh (--purge also deletes the config and
the log). The fans go back to macOS.
brew tap itswl/fanctl https://github.com/itswl/fanctl
brew trust --formula itswl/fanctl/fanctl # Homebrew 7 and later ask for this once
brew install fanctl
sudo brew services start fanctlHomebrew builds it in a second and runs it as a root service, also at every boot. The config is
$(brew --prefix)/etc/fancontrol.conf (the balanced preset) and the log
$(brew --prefix)/var/log/fancontrol.log; every fanctl command works the same. Use either
Homebrew or the one-line installer, not both.
A root service's files are made root-owned by brew services, so hand them back to yourself before
Homebrew changes them:
# upgrade
sudo chown -R "$(whoami)" "$(brew --cellar fanctl)"
brew upgrade fanctl && sudo brew services restart fanctl
# uninstall
sudo brew services stop fanctl
sudo chown -R "$(whoami)" "$(brew --cellar fanctl)"
brew uninstall fanctl| Preset | Takes over at | For |
|---|---|---|
balanced (default) |
55 °C | Everyday use. Light loads stay with macOS, so the fans can stop; under sustained load they start a little earlier and run a little faster. |
quiet |
62 °C | Less noise. Below 62 °C the fans are left to macOS; long full loads run a few degrees warmer. |
cool |
48 °C | Long builds, renders, model runs. Audible under medium load. |
lap |
40 °C | On your lap: the fans never stop, which keeps the bottom of the case cool. Faint noise at idle. |
full |
always | Every fan at full speed: burn-in, benchmarks, getting rid of heat fast. Loud. |
sudo fanctl preset quiet switches presets; it takes effect at once, and a config you edited by
hand is saved as fancontrol.conf.bak first. fanctl preset lists them and marks the active one.
fanctl status # temperatures, fans (manual/auto), preset, daemon, autostart
fanctl preset # list the presets (* = active)
sudo fanctl preset quiet # switch preset, takes effect at once
sudo fanctl stop # stop now; the fans go back to macOS
sudo fanctl start # start again
sudo fanctl autostart off # don't start at boot (on: start at boot, the default)
sudo fanctl reload # after editing /usr/local/etc/fancontrol.conf
fanctl temp # the PMU temperature the curve uses
fanctl run --dry-run --verbose # what the daemon would do, without touching the fansstop and start change only what runs now; autostart decides whether fanctl starts at boot.
fanctl set outranks the preset and every other rule, for 10 minutes unless you say otherwise;
then the preset carries on from the speed the fans are at:
sudo fanctl set 3000 # every fan at 3000 rpm for 10 minutes
sudo fanctl set 3000 30m # for 30 minutes (s, m, h)
sudo fanctl set 3000 always # until you end it
sudo fanctl set 1 2600 1h # fan 1 only; while it lasts the other fans are left to macOS
sudo fanctl set off # end it nowfanctl status shows what is set and until when. A manual setting survives a restart of fanctl, and
always survives a reboot. The only thing that ends it early is the CPU reaching its 110 °C limit:
then the fans go back to macOS. If the daemon is not running, set sets the fans directly and
nothing ends it on time.
/usr/local/etc/fancontrol.conf (Homebrew: $(brew --prefix)/etc/fancontrol.conf):
interval 3 # seconds between checks while fanctl drives the fans
auto_below 55 # below this PMU temperature macOS keeps the fans (they may stop)
auto_hysteresis 5 # hand back only below 55 - 5 = 50 °C, so it does not flap
confirm_up 5 # a rise must show on 5 checks in a row before fanctl reacts (default)
confirm_down 20 # a fall must show on 20 checks in a row (default)
cpu_guard 95 108 # CPU guard, this is the default; "cpu_guard off" disables it
curve_pct 50 1 # <°C> <percent of each fan's range: 0 = minimum, 100 = maximum>
curve_pct 60 13 # linear in between, flat beyond the ends
curve_pct 70 35
- The temperatures are PMU temperatures, not CPU core temperatures. On a 14-inch M3 Pro the PMU reads about 45 °C at idle and 50–60 °C under everyday load, while the CPU cores may already be above 85 °C. See How it works.
curve <°C> <rpm>takes absolute speeds instead ofcurve_pct; use one or the other. Absolute speeds only fit one model, percentages fit any Mac.cpu_guard <start> <full>: while fanctl drives the fans and the smoothed CPU temperature is above<start>, the fans run at least at (temperature − start) / (full − start) of their range, 100 % at<full>. Without it, the slow PMU temperature would keep the fans low during long full loads. CPU hot spots sit at 95–103 °C under ordinary sustained load, so the default starts at 95 °C.- Keep
auto_belowabove the idle temperature, or the fans never stop (lapdoes this on purpose). - After editing, run
sudo fanctl reload. It checks the file first; an invalid file is rejected and the old config stays in effect.
A change has to show on several checks in a row before fanctl acts on it, and then the fans move gradually:
| Confirmed by | Then | |
|---|---|---|
| Taking over | confirm_up checks at or above auto_below (default 5, about 12 s) |
starts from the speed the fans are already at |
| Faster fans | confirm_up checks wanting more |
ramps up by 2 % of the range per second (about 50 s from minimum to maximum) |
| Slower fans | confirm_down checks wanting less (default 20, about 1 min) |
ramps down by 0.5 % per second (about 3 min back) |
| Handing back | confirm_down checks below auto_below - auto_hysteresis |
macOS takes over from the current speed |
While a takeover is being confirmed macOS keeps cooling as usual. Two things skip the confirmation and hand the fans straight back to macOS, keeping fanctl out for two minutes: the smoothed CPU temperature reaching 110 °C (the chip's limit; macOS then throttles as well as cooling), and CPU sensors that cannot be read for three checks.
| Source | Sensors | Behaviour (14-inch M3 Pro) | Used for |
|---|---|---|---|
| HID | maximum of PMU tdie* (PMU tdev* if there are none) |
slow and steady: +3 °C after 25 s with five P-cores busy | curve, auto_below |
| SMC | TCMz (P-core maximum) and Tg* (GPU) |
fast and spiky: 73 → 85 °C within 2 s of the same load, 65–80 °C jumps at idle | cpu_guard, smoothed with a 10 s time constant |
Apple Silicon has no CPU core sensors in HID, and Intel-era SMC keys such as TC0P are gone. SMC
key names change between chip generations, so fanctl finds the CPU and GPU keys by prefix at
start-up and uses TCMz only after checking that it matches the maximum of the Tp* keys;
otherwise it reads every Tp*/Te*/Tg* key.
| Key | Meaning |
|---|---|
F<i>Ac |
actual speed |
F<i>Mn / F<i>Mx |
speed range |
F<i>Tg |
target speed (flt) |
F<i>Md (F<i>md on newer chips) |
mode (ui8): 1 = manual, 0 = SMC, 3 = macOS is managing the temperature |
Ftst |
one switch for all fans; only some MacBooks have it |
Easy to get wrong:
- A target is ignored unless
F<i>Mdis 1 first, and the write still reports success. Handing a fan back means writingF<i>Md = 0; a zero target alone does nothing. F<i>Mdis one byte. Written as a float, 1.0 puts 0x00 in the first byte.- A new target takes 30–100 ms to land; reading it back too early looks like a failure.
- Without root the SMC also reports success and does nothing, so every write is read back.
- The SMC throttles rapid writes. fanctl writes only when the target moves by more than 100 rpm, writes all fans in one batch and leaves at least 2 s between batches.
- If the system takes a fan back (the mode reads 0 or 3) or its target changes underneath, fanctl takes it over again on the next check.
- Some MacBooks refuse manual mode (mode 3) until
Ftstis 1. fanctl tries the direct write first and only then setsFtst. WhileFtstis 1 macOS stops managing the temperature, so it is set back to 0 whenever the fans are handed back, including on stop, exit and sleep.
Every sensor read is a call into the kernel, so fanctl reads only what the current state needs:
- While macOS has the fans, only the PMU temperature is read, and the next check is further away
the further the temperature is from
auto_below(it rises at most ~0.15 °C/s; fanctl assumes 0.5 °C/s), up to 30 s. - While fanctl drives the fans:
TCMzand the fan modes everyinterval, the PMU temperature every 2nd check (every check while a hand-back is being confirmed), the GPU sensors every 5th, the fan targets every 10th. Speed ranges are read once. - Signals arrive through kqueue: the process wakes once per check, or at once for a signal, and never polls.
- The log is written only when something changes: start, reload, a mode switch, a curve change of more than 3 %, the guard engaging or releasing, and failures at most once a minute.
Measured over 60 s, compared with reading every sensor every 3 s:
| context switches | system calls | CPU time | |
|---|---|---|---|
macOS in charge (balanced at idle) |
4,439 → 172 | 2,510 → 211 | 63 → 12 ms |
fanctl in charge (lap) |
4,592 → 563 | 2,710 → 590 | 65 → 20 ms |
Resident memory is about 3 MB.
- If the temperature cannot be read three times in a row, the fans go back to macOS.
- Sleep: fanctl never holds off sleep or hibernation. Before the Mac sleeps it hands the fans back to macOS, so a sleeping Mac's fans behave as without fanctl; after waking it starts over, and macOS keeps the fans until a takeover is confirmed again.
- No signal handlers (IOKit is not async-signal-safe): TERM/INT/HUP are collected through kqueue and handled in the main loop. TERM hands the fans back before exiting, HUP reloads the config.
- The LaunchDaemon uses
ProcessTypeStandard(utility QoS, priority 20). With every core busy, a 500 ms sleep wakes 180–210 ms late on average asBackground(priority 4), 56 ms late asStandard. - Only one
fanctl runcan drive the fans at a time (/var/run/fanctl.lock).
- fanctl writes real hardware state and needs root; uninstalling hands the fans back to macOS.
- Don't run other fan-control software at the same time; they would fight over the fans.
- A hand-written curve is not smarter than the built-in control, which sees more sensors and reacts faster. fanctl is for preferences: quieter, cooler, or steady airflow.
- Intel Macs are not supported; on Macs without fans the daemon exits.
- Tested on a 14-inch MacBook Pro (M3 Pro, Mac15,6, 2 fans, 2317–6800 rpm) with macOS 14.4. The
Ftstpath for MacBooks that lock fan control follows published research and is not yet tested on such a Mac.