psutil Tutorial: Monitor CPU, Memory, Disk and Processes in Python (2026)

Automation Nov 12, 2026

Watch on YouTube

Is the server running out of memory? Which process is eating the CPU? Is the disk nearly full before tonight's backup? You can answer all of these from Python with psutil (process and system utilities), a small cross-platform library that reads CPU, memory, disk, network and process information on Windows, Linux and macOS through one API. In this tutorial we print a system overview, list the top processes by memory, start and watch a child process and then stop it cleanly, measure live network and disk speed, and finish with a tiny health monitor that logs to CSV and flags alerts. Everything was tested on Python 3.13.5 with psutil 7.2.2, and every output block below is the real output of the script above it (from an 8-core Linux machine, so your numbers will differ).

Why psutil in 2026?

  • One API for every OS: no parsing of top, free, df or tasklist output; the same call works on Windows, Linux and macOS.
  • System and process level: machine-wide CPU, RAM, swap, disks and network, plus per-process memory, CPU, threads, open files and children.
  • Prebuilt wheels: pip install psutil installs a compiled wheel on all mainstream platforms, so there is nothing to build.
  • Python 3 only: the 7.x series dropped Python 2.7 support, so old snippets with Python 2 syntax will not apply; the API used here is current.
  • Used everywhere: monitoring agents, test runners, Jupyter extensions and task queues use psutil under the hood, so the skills transfer.

Install

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install psutil
python -c "import psutil; print(psutil.__version__)"

psutil has no dependencies. Everything else in this guide (csv, datetime, time) comes from the standard library.

Step 1: CPU, RAM, disk and uptime overview

These five calls are the core of most monitoring scripts. Note the interval=1 in cpu_percent(): psutil compares two snapshots, so it blocks for one second and returns the real average. Without an interval, the first call returns a meaningless 0.0.

import datetime as dt
import psutil

GB = 1024 ** 3
print("CPU cores (logical/physical):", psutil.cpu_count(), "/", psutil.cpu_count(logical=False))
print("CPU usage over 1 s:", psutil.cpu_percent(interval=1), "%")

mem = psutil.virtual_memory()
print(f"RAM: {mem.used / GB:.1f} GB used of {mem.total / GB:.1f} GB "
      f"({mem.percent}%), available {mem.available / GB:.1f} GB")

disk = psutil.disk_usage("/")
print(f"Disk /: {disk.used / GB:.0f} GB used of {disk.total / GB:.0f} GB ({disk.percent}%)")

boot = dt.datetime.fromtimestamp(psutil.boot_time())
uptime = dt.datetime.now() - boot
print("Uptime:", str(uptime).split(".")[0])
CPU cores (logical/physical): 8 / 8
CPU usage over 1 s: 1.6 %
RAM: 3.0 GB used of 15.6 GB (19.0%), available 12.7 GB
Disk /: 95 GB used of 127 GB (74.5%)
Uptime: 1 day, 0:56:07

For "how much memory is free", use available, not free. On Linux, free excludes cache that the kernel can release instantly, so it looks alarmingly low on a healthy machine. On Windows, pass a drive such as psutil.disk_usage("C:\\") instead of "/".

Step 2: List running processes and find the memory hogs

psutil.process_iter() yields every process. Passing a list of attribute names fetches them in one efficient call and stores them in p.info. Processes can exit while you loop, and some system processes deny access, so always guard the loop.

import psutil

MB = 1024 ** 2
procs = []
for p in psutil.process_iter(["pid", "name", "memory_info", "num_threads"]):
    try:
        procs.append((p.info["memory_info"].rss, p.info["pid"],
                      p.info["name"], p.info["num_threads"]))
    except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError):
        continue   # process ended or is protected

print(f"{len(procs)} processes running. Top 5 by memory (RSS):")
print(f"{'PID':>7}  {'NAME':<16}{'RSS MB':>9}{'THREADS':>9}")
for rss, pid, name, threads in sorted(procs, reverse=True)[:5]:
    print(f"{pid:>7}  {name[:15]:<16}{rss / MB:>9.1f}{threads:>9}")
126 processes running. Top 5 by memory (RSS):
    PID  NAME               RSS MB  THREADS
1595650  node                934.6       12
1587719  node                260.5       11
   6946  orbitd              175.0        9
   5673  node                149.8       11
    423  node                122.2       11

When you request attributes this way, a value you are not allowed to read comes back as None instead of raising, which is why AttributeError is caught too. RSS (resident set size) is the physical memory the process uses right now, the same figure Task Manager and top show.

Step 3: Start, watch and stop a process

psutil.Popen works like subprocess.Popen but also gives you every Process method. Here we launch a child Python process that keeps one core busy and holds about 80 MB, sample it three times, then terminate it and wait for it to exit.

import sys, time
import psutil

# Start a child Python process that burns CPU and holds ~80 MB of RAM
code = "import time; data = bytearray(80 * 1024 * 1024)\nwhile True: sum(range(10000))"
child = psutil.Popen([sys.executable, "-c", code])
time.sleep(1)                       # let it allocate memory

child.cpu_percent(None)             # first call primes the counter
for i in range(3):
    time.sleep(1)
    rss = child.memory_info().rss / 1024 ** 2
    print(f"sample {i + 1}: pid={child.pid} status={child.status()} "
          f"cpu={child.cpu_percent(None):.0f}% rss={rss:.0f} MB")

child.terminate()                   # ask it to stop (SIGTERM)
gone, alive = psutil.wait_procs([child], timeout=3)
print("stopped:", [p.pid == child.pid for p in gone], "still alive:", len(alive))
print("exists now?", psutil.pid_exists(child.pid))
sample 1: pid=1654435 status=running cpu=104% rss=88 MB
sample 2: pid=1654435 status=running cpu=100% rss=88 MB
sample 3: pid=1654435 status=running cpu=99% rss=88 MB
stopped: [True] still alive: 0
exists now? False

Two details matter. First, the first cpu_percent(None) call only primes the counter; later calls measure since the previous one. Second, process CPU is measured per core, so a busy single-threaded process reads about 100%, and the 104% sample is normal timing jitter; on an 8-core machine a fully loaded process could show up to 800%. For a process you did not start, use psutil.Process(pid), and prefer terminate() followed by wait_procs() before falling back to kill().

Step 4: Measure live network and disk speed

net_io_counters() and disk_io_counters() return running totals since boot. To get speed, take two snapshots and divide the difference by the elapsed time.

import time
import psutil

net1, disk1 = psutil.net_io_counters(), psutil.disk_io_counters()
time.sleep(2)
net2, disk2 = psutil.net_io_counters(), psutil.disk_io_counters()

kb = lambda b: b / 1024 / 2       # bytes over 2 s -> KB/s
print(f"network: down {kb(net2.bytes_recv - net1.bytes_recv):.1f} KB/s, "
      f"up {kb(net2.bytes_sent - net1.bytes_sent):.1f} KB/s")
print(f"disk:    read {kb(disk2.read_bytes - disk1.read_bytes):.1f} KB/s, "
      f"write {kb(disk2.write_bytes - disk1.write_bytes):.1f} KB/s")
nics = [n for n, s in psutil.net_if_stats().items() if s.isup]
print("interfaces up:", ", ".join(sorted(nics)))
network: down 8.6 KB/s, up 8.9 KB/s
disk:    read 0.0 KB/s, write 62.0 KB/s
interfaces up: enp0s3, lo

Pass pernic=True or perdisk=True to get one entry per network card or disk. On some containers and virtual machines disk_io_counters() can return None, so check for that in production code.

Step 5: A tiny health monitor that logs to CSV

Put it together: sample CPU, RAM and disk once per second, write each sample to a CSV file and label any row that crosses a threshold. Run it from Task Scheduler or cron with a longer loop, and you have a lightweight monitor with no agent to install.

import csv, datetime as dt
import psutil

CPU_LIMIT, MEM_LIMIT = 85.0, 90.0     # alert thresholds in %

with open("health_log.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["time", "cpu_pct", "mem_pct", "disk_pct", "alert"])
    for _ in range(5):
        cpu = psutil.cpu_percent(interval=1)       # one sample per second
        mem = psutil.virtual_memory().percent
        disk = psutil.disk_usage("/").percent
        alert = "HIGH CPU" if cpu > CPU_LIMIT else "HIGH RAM" if mem > MEM_LIMIT else ""
        w.writerow([dt.datetime.now().strftime("%H:%M:%S"), cpu, mem, disk, alert])

rows = list(csv.DictReader(open("health_log.csv")))
for r in rows:
    print(r["time"], f"cpu={r['cpu_pct']}%", f"mem={r['mem_pct']}%",
          f"disk={r['disk_pct']}%", r["alert"] or "ok")
print("rows logged:", len(rows), "| max cpu:", max(float(r["cpu_pct"]) for r in rows), "%")
00:57:46 cpu=1.0% mem=19.2% disk=74.5% ok
00:57:47 cpu=1.0% mem=19.1% disk=74.5% ok
00:57:48 cpu=0.4% mem=19.1% disk=74.5% ok
00:57:49 cpu=0.7% mem=19.1% disk=74.5% ok
00:57:50 cpu=1.0% mem=19.1% disk=74.5% ok
rows logged: 5 | max cpu: 1.0 %

The machine was idle, so every row says ok. In a real job, replace the CSV write or add a step that e-mails you when alert is not empty, and keep the thresholds in a config file so you can tune them without editing code.

Quick reference

Taskpsutil call
CPU usage (%)psutil.cpu_percent(interval=1)
Per-core usagepsutil.cpu_percent(interval=1, percpu=True)
Core countpsutil.cpu_count(), cpu_count(logical=False)
RAM total / availablepsutil.virtual_memory()
Swappsutil.swap_memory()
Disk spacepsutil.disk_usage("/")
Network / disk totalspsutil.net_io_counters(), psutil.disk_io_counters()
All processespsutil.process_iter(["pid", "name"])
One processpsutil.Process(pid)
Stop processesp.terminate() + psutil.wait_procs([p], timeout=3)
Uptimepsutil.boot_time()
Batterypsutil.sensors_battery() (laptops)

Common problems and fixes

  • cpu_percent() always returns 0.0: you called it once without an interval. Pass interval=1, or call it once to prime and read it again later.
  • psutil.AccessDenied: the process belongs to another user or the system. Catch the exception, or run with administrator or root rights if you really need it.
  • psutil.NoSuchProcess: the process ended between listing and reading it. Catch it and move on; this is normal.
  • Process CPU above 100%: it is per core. Divide by psutil.cpu_count() if you want a 0 to 100 scale for the whole machine.
  • Memory looks almost full on Linux: read virtual_memory().available or .percent, not .free.
  • sensors_temperatures missing: temperature sensors are only exposed on Linux and FreeBSD; on Windows and macOS the function does not exist.

Where to go next

Conclusion

psutil turns system monitoring into plain Python: cpu_percent(), virtual_memory() and disk_usage() for machine health, process_iter() and Process for finding and controlling programs, and the I/O counters for live network and disk speed. With a loop and a CSV writer you already have a cross-platform monitor, and it is a solid building block for automation scripts in 2026.

Share:
Last updated: November 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices