<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Supun Sriyananda</title>
    <description>The latest articles on DEV Community by Supun Sriyananda (@ranaweerasupun).</description>
    <link>https://dev.to/ranaweerasupun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951298%2Fb6d6c160-d027-48c3-aeb0-91a507d5b6a8.jpeg</url>
      <title>DEV Community: Supun Sriyananda</title>
      <link>https://dev.to/ranaweerasupun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ranaweerasupun"/>
    <language>en</language>
    <item>
      <title>What ioctl(KVM_RUN) Does: The VM Exit Loop Explained</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:10:18 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/what-ioctlkvmrun-does-the-vm-exit-loop-explained-4ok5</link>
      <guid>https://dev.to/ranaweerasupun/what-ioctlkvmrun-does-the-vm-exit-loop-explained-4ok5</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99"&gt;previous article&lt;/a&gt; established that guest code runs natively until it touches a device, at which point control has to reach QEMU.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt; is the single most important line in the entire stack. Libvirt, the XML, the device models and VirtIO are just scaffolding around this one call, looped forever.&lt;/p&gt;

&lt;p&gt;We'll build up to it step by step, because the this call only makes sense once you know what a file descriptor is and how &lt;code&gt;ioctl&lt;/code&gt; works generally.&lt;/p&gt;

&lt;h2&gt;
  
  
  File descriptors, briefly
&lt;/h2&gt;

&lt;p&gt;Before I explained what &lt;code&gt;ioctl&lt;/code&gt; is let me refresh your memory about file descriptors. If you don't need a memory refresh, you can skip this section.&lt;/p&gt;

&lt;p&gt;Simply put, a file descriptor (FD) is a non-negative integer that serves as a unique identifier for any resource a process has open.&lt;/p&gt;

&lt;p&gt;The operating system kernel maintains a private lookup table for every running process. Let's say your program opens a resource. The kernel then creates a tracking entry for the underlying object and hands back the index of that slot. From that moment on, your program refers to that resource exclusively by its number.&lt;/p&gt;

&lt;p&gt;By default, three descriptors are pre-allocated when a process spawns: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 for standard input (stdin), &lt;/li&gt;
&lt;li&gt;1 for standard output (stdout), and &lt;/li&gt;
&lt;li&gt;2 for standard error (stderr). &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next resource you open grabs the lowest available integer, typically starting at 3. It doesn't matter whether you are interacting with a plain text file, a network socket, a hardware component, or a KVM virtual CPU, the kernel handles them all through file descriptors using uniform system calls like read(), write(), and close(). And the kernel automatically figures out how to route your data based on what that specific integer points to.&lt;/p&gt;

&lt;p&gt;Let me show you the outputs from my raspberry pi 5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;home1@home1:~ &lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/self/fd
total 0
lrwx------ 1 home1 home1 64 Sep  7 07:32 0 -&amp;gt; /dev/pts/4
lrwx------ 1 home1 home1 64 Sep  7 07:32 1 -&amp;gt; /dev/pts/4
lrwx------ 1 home1 home1 64 Sep  7 07:32 2 -&amp;gt; /dev/pts/4
lr-x------ 1 home1 home1 64 Sep  7 07:32 3 -&amp;gt; /proc/3311761/fd

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the numbers on the far right. FDs 0, 1, and 2 are all pointing to &lt;code&gt;/dev/pts/4&lt;/code&gt;. This is the virtual terminal window you are looking at. It says your input, output, and errors are all routed to your screen! Do you see the the number 3 at the end? That is the &lt;code&gt;ls&lt;/code&gt; command itself reading the directory table, grabbing the lowest available integer.&lt;/p&gt;

&lt;h2&gt;
  
  
  ioctl: the escape hatch for everything read and write can't express
&lt;/h2&gt;

&lt;p&gt;Standard &lt;code&gt;read()&lt;/code&gt; and &lt;code&gt;write()&lt;/code&gt; operations excel at shifting raw bytes back and forth but they fall short when you need to &lt;em&gt;configure&lt;/em&gt; the underlying hardware itself.&lt;/p&gt;

&lt;p&gt;For example, you cannot &lt;code&gt;write()&lt;/code&gt; to an CD drive to force it to eject. Similarly, there's no meaningful way to &lt;code&gt;read()&lt;/code&gt; a serial port's baud rate, or to express "change it to 115200" as a byte stream. This is because these are &lt;em&gt;control&lt;/em&gt; operations on a device, not data transfers.        &lt;/p&gt;

&lt;p&gt;The Unix architecture solves this limitation with &lt;code&gt;ioctl&lt;/code&gt; (Input/Output Control). &lt;code&gt;ioctl&lt;/code&gt; is a highly generic system call designed specifically to send arbitrary commands to whatever resource a file descriptor references.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;ioctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ioctl(fd, REQUEST, argument)
          │     │        │
          │     │        └─ Optional pointer to custom data structures
          │     └────────── The specific command ID (a unique integer)
          └──────────────── The file descriptor you are talking to

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, the driver handling the device interprets what each command integer means. This serves as a universal extension mechanism. And it allows any hardware driver to expose custom routines that the standard filesystem interface never planned for. For example, terminals rely on it to adjust window dimensions, network adapters use it to bind IP configurations, and KVM leverages it to orchestrate an entire virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  KVM's three-level ladder of descriptors
&lt;/h2&gt;

&lt;p&gt;KVM exposes itself as &lt;code&gt;/dev/kvm&lt;/code&gt;, a character device. QEMU opens it and gets a descriptor. That descriptor and a stream of &lt;code&gt;ioctl&lt;/code&gt; calls is the &lt;em&gt;entire&lt;/em&gt; QEMU-to-KVM interface.&lt;/p&gt;

&lt;p&gt;To manage complex hardware setups better, KVM organizes this interface into a strict, three-tiered hierarchy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjw759cvx6c6mr0bh43y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjw759cvx6c6mr0bh43y.png" alt="kvms-three-descriptors"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a VM is created, things descent down this ladder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;System FD (&lt;code&gt;KVM_CREATE_VM&lt;/code&gt; ): Allocates a blank, unconfigured VM instance and hands back a unique VM file descriptor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KVM_SET_USER_MEMORY_REGION&lt;/code&gt; on the VM fd — "here's memory I allocated; treat it as the guest's physical RAM." QEMU allocates guest memory as an ordinary &lt;code&gt;mmap&lt;/code&gt; region in its own address space and registers it with KVM. This is the sandbox boundary: the guest can only address what was registered.&lt;/li&gt;
&lt;li&gt;VM FD (&lt;code&gt;KVM_SET_USER_MEMORY_REGION&lt;/code&gt;): Defines the virtual sandbox. The Virtual Machine Monitor (VMM) reserves a normal block of userspace memory via mmap, then registers it here. The KVM kernel module transforms this space into the guest's physical RAM layout. It also ensures the guest can never read or write outside this boundary.&lt;/li&gt;
&lt;li&gt;VM FD (&lt;code&gt;KVM_CREATE_VCPU&lt;/code&gt;): Spawns an execution core inside the VM structure and maps it to a fresh vCPU file descriptor.&lt;/li&gt;
&lt;li&gt;vCPU FD (&lt;code&gt;KVM_RUN&lt;/code&gt;): Instructs the host processor to immediately start executing guest instructions on this specific core. &lt;strong&gt;"run this CPU now."&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What KVM_RUN actually does
&lt;/h2&gt;

&lt;p&gt;Let's zoom in. The &lt;code&gt;KVM_RUN&lt;/code&gt; system call does not behave like a standard, predictable function call. Say a VMM thread(a QEMU vCPU thread) invokes &lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt;. This thread immediately &lt;strong&gt;enters the guest&lt;/strong&gt;. And it does not return to your user-space application until the guest does something KVM cannot handle alone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fich9zfihhw4h0imwzx7t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fich9zfihhw4h0imwzx7t.png" alt="the-call-that-doesnt-return"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The call blocks on purpose for extended periods.&lt;/strong&gt; Just think about it. From QEMU's perspective, it called a function and that function didn't return for millions of guest instructions. And the entire time the guest is computing, the QEMU thread is parked inside a single &lt;code&gt;ioctl&lt;/code&gt;. QEMU isn't polling or supervising anything. This is elegant, isn't it? It is asleep in a system call and wakes up only when there's work for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context switching across the guest boundary is expensive.&lt;/strong&gt; Entering the guest means loading the registers of the guest into the real core; exiting means saving them back out. This is not free and it's part of why an exit is costly!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every VM exit returns to QEMU.&lt;/strong&gt; If the guest triggers an exit that the Linux kernel can settle internally, KVM resets the guest state and jumps right back into guest mode. For example, when servicing a hardware timer or adjusting a local interrupt controller the user-space program QEMU stays asleep, completely unaware that an exit ever occurred in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheap exits and expensive exits
&lt;/h2&gt;

&lt;p&gt;There are two tiers of VM exits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap Exits (Kernel-Handled):&lt;/strong&gt; KVM resolves these entirely within the kernel module and immediately re-enters the guest. The &lt;code&gt;ioctl&lt;/code&gt; call never returns. And your VMM thread stays fast asleep. QEMU never learns it happened. Virtual timer ticks fall into this tier. So do accesses to devices KVM emulates in-kernel on ARM64, most importantly the &lt;strong&gt;GIC&lt;/strong&gt;. GIC is the Generic Interrupt Controller. Interrupt handling is far too frequent to route through userspace, so KVM handles it directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expensive Exits (User-Space Handled):&lt;/strong&gt; Other exits KVM cannot resolve, because the guest touched a device that exists only as a QEMU device model. So, now the &lt;code&gt;ioctl&lt;/code&gt; genuinely has to return. QEMU wakes up, runs the relevant C function, and calls &lt;code&gt;KVM_RUN&lt;/code&gt; again. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvwe1y8hquv91de3fosj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwvwe1y8hquv91de3fosj.png" alt="cheap-and-expensive-exits"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When people say "VM exits are expensive," they mean the second kind. Because as I showed you, first is comparatively cheap.&lt;/p&gt;

&lt;p&gt;This distinction is also why the in-kernel GIC exists at all. In the part 2 of the series the interrupt-related warning on Raspberry Pi from &lt;a href="https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm"&gt;article 2&lt;/a&gt; is worth understanding at this point. It generated more exits than a pristine hardware environment would, but the cheap ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  How QEMU learns what happened: the shared kvm_run struct
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;KVM_RUN&lt;/code&gt; does return, QEMU needs to know why. Passing that back through the simple numeric return value of &lt;code&gt;ioctl&lt;/code&gt; would be far too limited, so KVM uses shared memory.&lt;/p&gt;

&lt;p&gt;When a vCPU is created, KVM gives QEMU a small region mapped into both QEMU's address space and the kernel's. KVM writes the exit details into this &lt;code&gt;struct kvm_run&lt;/code&gt; before returning. Then QEMU reads them immediately after. There is no need for additional system calls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fir8afs9bxjq7c0qty97w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fir8afs9bxjq7c0qty97w.png" alt="the-shared-kvm-run-struct"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exit_reason&lt;/code&gt; is the dispatch key. &lt;code&gt;KVM_EXIT_MMIO&lt;/code&gt; means the guest accessed a memory-mapped device region. This is the common case on ARM64, and we discussed the mechanism in &lt;a href="https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99"&gt;article 4&lt;/a&gt;. QEMU looks up which device model owns that address, calls it, and loops.&lt;/p&gt;

&lt;p&gt;Follow the example concretely. The guest writes the character &lt;code&gt;H&lt;/code&gt; to the UART at &lt;code&gt;0x09000000&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The guest executes a store instruction. It has no idea anything unusual is happening.&lt;/li&gt;
&lt;li&gt;The address is in a device region, so the CPU traps. VM exit.&lt;/li&gt;
&lt;li&gt;KVM sees it's MMIO to an address it doesn't handle in-kernel. It fills in &lt;code&gt;kvm_run&lt;/code&gt; and returns from &lt;code&gt;ioctl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;QEMU wakes, reads &lt;code&gt;exit_reason&lt;/code&gt;, dispatches to its PL011 UART model.&lt;/li&gt;
&lt;li&gt;The UART model writes &lt;code&gt;H&lt;/code&gt; to whatever the serial console is connected to — your terminal.&lt;/li&gt;
&lt;li&gt;QEMU calls &lt;code&gt;ioctl(KVM_RUN)&lt;/code&gt; again. The guest resumes at the next instruction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every character of output from a guest's serial console runs that loop. When you watch an OS installer scroll past over &lt;code&gt;virsh console&lt;/code&gt;, you are watching this cycle execute thousands of times.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7ubhv6bx9q38i7zrwaj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb7ubhv6bx9q38i7zrwaj.png" alt="one-character-to-the-uart"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the foundation for everything that we will follow
&lt;/h2&gt;

&lt;p&gt;When you can clear your head around this round trip, several later topics becomes easier to grasp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VirtIO&lt;/strong&gt; (&lt;a href="https://bittobyteacademy.com/blog/why-virtio-is-faster-virtqueues-and-vhost-net-explained/" rel="noopener noreferrer"&gt;article 6&lt;/a&gt;) is the architectural response to expensive exits. If each round trip costs, then we can make each one carry more work. VirtIO batches many operations behind a single notification, amortizing one exit across dozens of requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vCPU pinning&lt;/strong&gt; Remember that the host thread driving our &lt;code&gt;KVM_RUN&lt;/code&gt; cycle is treated by Linux as an ordinary, everyday thread. So, if you don't intervene, the Linux kernel scheduler will happily shift that thread from one physical core to another to balance host workloads, like it usually does. But, if you pinning your vCPU thread to a single core, you ensure the guest state stays perfectly warm inside that specific core's local cache lines between transitions. Now imagine you let that thread wander across your topology. Then every VM exit drops you onto a cold core. This forces the hardware to reload state variables from scratch and destroys your execution speeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;QMP versus KVM_RUN&lt;/strong&gt; Don't confuse QMP(QEMU Machine Protocol) with &lt;code&gt;KVM_RUN&lt;/code&gt;. I know I did at first. These are two entirely separate communication paths. QMP is a standard Unix/TCP socket interface that management utilities like &lt;code&gt;libvirt&lt;/code&gt; use to command QEMU from the outside. &lt;code&gt;KVM_RUN&lt;/code&gt; is the internal system call (&lt;code&gt;ioctl&lt;/code&gt;) that QEMU executes to hand control over to the Linux kernel module and physical hardware.&lt;/p&gt;

&lt;p&gt;Keep this in mind: &lt;strong&gt;&lt;code&gt;ioctl(vcpu_fd, KVM_RUN)&lt;/code&gt; commands execution, the guest computes directly on bare-metal hardware until a trap occurs, and the system call unblocks only when QEMU device emulation is required.&lt;/strong&gt; That call, when looped forever, is what constitutes a running virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;file descriptor&lt;/strong&gt; is an integer indexing a per-process table of open kernel objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ioctl&lt;/code&gt;&lt;/strong&gt; is the generic system call for device-specific commands that &lt;code&gt;read&lt;/code&gt; and &lt;code&gt;write&lt;/code&gt; can't express.&lt;/li&gt;
&lt;li&gt;KVM is driven entirely through &lt;code&gt;ioctl&lt;/code&gt; on &lt;code&gt;/dev/kvm&lt;/code&gt;, via a three-level ladder: system fd → VM fd → vCPU fd.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KVM_RUN&lt;/code&gt; on a vCPU fd enters the guest and blocks for as long as the guest runs natively — potentially millions of instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap exits&lt;/strong&gt; are handled inside KVM (timers, the in-kernel GIC) and never wake QEMU. &lt;strong&gt;Expensive exits&lt;/strong&gt; return to userspace so a QEMU device model can run.&lt;/li&gt;
&lt;li&gt;KVM reports why it exited through the shared &lt;code&gt;kvm_run&lt;/code&gt; struct; &lt;code&gt;exit_reason&lt;/code&gt; tells QEMU which device model to dispatch to.&lt;/li&gt;
&lt;li&gt;Every character on a guest's serial console is one full round trip through this loop.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>linux</category>
      <category>devops</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>How QEMU Emulates Hardware: Device Models, MMIO, and TCG</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99</link>
      <guid>https://dev.to/ranaweerasupun/how-qemu-emulates-hardware-device-models-mmio-and-tcg-2b99</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/blog/kvm-vs-qemu-vs-libvirt-who-does-what/"&gt;previous article&lt;/a&gt; established that QEMU emulates the devices KVM deliberately doesn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We constantly hear the phrase, “QEMU emulates devices.” It is treated like a magic trick. A user-space program somehow conjures a virtual disk or a network card out of thin air, and a guest operating system is perfectly fooled.&lt;/p&gt;

&lt;p&gt;But there is no black box. The entire illusion relies on a single mechanism which is applied consistently across every piece of virtual hardware. Once you see this mechanism, every performance characteristic of virtualization makes sense. Why does VirtIO exist, why certain operations feel catastrophically slower in a VM than on bare metal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before We Begin: QEMU is Two Different Programs
&lt;/h2&gt;

&lt;p&gt;We need to clear up a common point of confusion before looking at the code or going further. QEMU actually does two entirely different jobs depending on how you launch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full-system emulation&lt;/strong&gt; (&lt;code&gt;qemu-system-*&lt;/code&gt;) builds a virtual environment from scratch to boot a complete guest operating system. This is what Libvirt launches for every VM, and it is the sole focus of this series. For example, &lt;code&gt;qemu-system-aarch64&lt;/code&gt; constructs a complete, virtualized motherboard from scratch, including the CPU, RAM, disk controllers, network cards, and firmware—allowing a full guest OS to boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User-mode emulation&lt;/strong&gt; (&lt;code&gt;qemu-*&lt;/code&gt;) executes a single binary compiled for a foreign architecture directly on your host OS by translating its system calls on the fly. This engine doesn't build a machine or boot an OS. It's how you run an x86 binary on an ARM machine without a VM. And it is what Docker's &lt;code&gt;binfmt_misc&lt;/code&gt; cross-architecture support uses under the hood. This is useful, but not virtualization.&lt;/p&gt;

&lt;p&gt;Note that, this series is strictly about full-system emulation. If a command begins with &lt;code&gt;qemu-system-&lt;/code&gt;, it is fabricating an entire computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: devices live at memory addresses
&lt;/h2&gt;

&lt;p&gt;The mechanism that makes emulation possible relies on a fundamental truth about real hardware:&lt;/p&gt;

&lt;p&gt;When an operating system wants to send a byte to a serial port, it doesn't call a function. Instead, it &lt;strong&gt;writes to a specific memory address&lt;/strong&gt;. This memory address is physically wired directly to the serial chip on the motherboard. As you can see no RAM is ever involved here. The motherboard's address decoder routes the signal straight to the hardware registers instead.&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;memory-mapped I/O&lt;/strong&gt;, or MMIO, and it's how the CPU talks to essentially every device on a modern ARM system. So, the devices are not special entities the CPU has a private channel to. The devices are just regions of the address space.&lt;/p&gt;

&lt;p&gt;Which means the CPU's entire interface to a device comes down to this: &lt;em&gt;writing to certain addresses and reading from certain addresses.&lt;/em&gt; That's it. Nothing else. And this is an interface software can easily impersonate!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv2tl70j0z18fhhir5b4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv2tl70j0z18fhhir5b4.png" alt="The same write to the same address. Only the far end differs, and the driver never finds out." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;QEMU maps out a guest address space where specific regions are intentionally flagged as "device space" rather than RAM. Now, let's imagine this guest attempts to access one of these coordinates. This operation is blocked from executing as a memory task. Instead, it triggers a trap. Then QEMU runs a dedicated software function.&lt;/p&gt;

&lt;p&gt;That software function &lt;em&gt;is&lt;/em&gt; the device. For example, a virtual UART is just a few hundred lines of C code that prints a character to your terminal when a write occurs. A virtual disk controller is C code that intercepts a guest read request and fetches bytes from a &lt;code&gt;.qcow2&lt;/code&gt; file on your host SSD.&lt;/p&gt;

&lt;p&gt;Let's me show you this in code. The the following is a simplified, structural layout of what that C code of this software function looks like. &lt;strong&gt;Don't overwhelm yourself with all the details in the code. Just take a look at it. I just want show you that there is no magic here.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Handoff Interface (MemoryRegionOps)
&lt;/h3&gt;

&lt;p&gt;First of all, QEMU needs to map the memory region to specific C functions. It registers a structure that tells the engine exactly what code to execute when a guest touches the device's addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* This structure acts as the router for guest hardware accesses */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MemoryRegionOps&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="cm"&gt;/* Function to run when guest reads from an address */&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uart_mmio_write&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* Function to run when guest writes to an address */&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endianness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DEVICE_NATIVE_ENDIAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_access_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_access_size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Software Function (uart_mmio_write)
&lt;/h3&gt;

&lt;p&gt;Let's imagine that the guest writes to 0x09000000. When it does, the hardware trap catches it, and QEMU executes this exact type of software function. You can see that it is just a standard C switch statement mapping address offsets to software behaviors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* The software function that IS the device hardware channel */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;uart_mmio_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;opaque&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hwaddr&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
                            &lt;span class="kt"&gt;uint64_t&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;UARTState&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UARTState&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;opaque&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cm"&gt;/* The offset is how far past the base address (0x09000000) the guest wrote */&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x00&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Data Register (TX Buffer)&lt;/span&gt;
            &lt;span class="cm"&gt;/* The guest wants to transmit a character. 
               Instead of triggering silicon, we print it to your host terminal. */&lt;/span&gt;
            &lt;span class="n"&gt;putchar&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
            &lt;span class="n"&gt;fflush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="cm"&gt;/* Tell our software model that an interrupt is ready (Data sent) */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;uart_status&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="n"&gt;STATUS_TX_EMPTY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; 
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x04&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Interrupt Enable Register&lt;/span&gt;
            &lt;span class="cm"&gt;/* The guest is trying to toggle hardware interrupt lines */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;interrupt_mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mh"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Baud Rate Divisor&lt;/span&gt;
            &lt;span class="cm"&gt;/* A real chip would change electrical frequencies here.
               QEMU just updates an integer variable in RAM. */&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;baud_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;115200&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nl"&gt;default:&lt;/span&gt;
            &lt;span class="cm"&gt;/* Guest tried to write to an unmapped register address */&lt;/span&gt;
            &lt;span class="n"&gt;log_bad_hardware_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There is no magic anywhere in this.&lt;/strong&gt; Every device your guest sees is a software model like this. A chunk of code pretending to be a chip. It does this well by responding to the same address accesses the real chip would respond to. And the guest's driver cannot tell the difference, because from the driver's perspective there &lt;em&gt;is&lt;/em&gt; no difference.THe guest driver writes to an address and something happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqguw3bsxewn63en33kg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffqguw3bsxewn63en33kg.png" alt="Every region here is reached the same way. Only one of them is memory." width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the guest's firmware finds these devices at all
&lt;/h2&gt;

&lt;p&gt;This architecture raises an obvious question: how does the guest OS know that a serial port is exactly at &lt;code&gt;0x09000000&lt;/code&gt; in the first place?&lt;/p&gt;

&lt;p&gt;On a physical ARM board, this layout is explicitly dictated by something called a Device Tree. A device tree is a structured data file compiled into the firmware that outlines exactly what hardware components exist and what memory addresses they occupy. x86 has legacy hardware discovery conventions, but ARM has no equivalent of this. I won't go into details about x86 architecture because the series is about ARM. Just remember that in ARM the entire motherboard topology must be spelled out line-by-line for the kernel at boot.&lt;/p&gt;

&lt;p&gt;QEMU generates a this device tree describing the machine it's emulating, and hands it to the guest at boot. The guest kernel reads it and sees "there's a PL011 UART at &lt;code&gt;0x09000000&lt;/code&gt;," loads the PL011 driver, and starts writing to that address. The driver is the standard, unmodified Linux driver for real PL011 hardware. It has no idea it's talking to C code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The device tree
&lt;/h3&gt;

&lt;p&gt;Here is a highly simplified, annotated snippet of a real Device Tree Source (.dts). Again, just take a look only to recognize there is no magic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
dts/dts-v1/;

/ {
    interrupt-parent = &amp;lt;0x8001&amp;gt;;
    #address-cells = &amp;lt;0x02&amp;gt;;
    #size-cells = &amp;lt;0x02&amp;gt;;

    /* Tells the Linux kernel this is QEMU's standardized virtual board */
    model = "linux,dummy-virt";              /* Identifies this as the synthetic 'virt' board */
    compatible = "linux,dummy-virt";

    /* Core CPU Configuration (e.g., 2 vCPUs requested) */
    cpus {
        #address-cells = &amp;lt;0x01&amp;gt;;
        #size-cells = &amp;lt;0x00&amp;gt;;

        cpu@0 {
            device_type = "cpu";
            compatible = "arm,cortex-a57";
            reg = &amp;lt;0x00&amp;gt;;
        };

        cpu@1 {
            device_type = "cpu";
            compatible = "arm,cortex-a57";
            reg = &amp;lt;0x01&amp;gt;;
        };
    };

    /* System Memory Layout (e.g., -m 2048M starts at base address 0x40000000) */
    memory@40000000 {
        device_type = "memory";
        reg = &amp;lt;0x00 0x40000000 0x00 0x80000000&amp;gt;;
    };

    /* The Interrupt Controller (GIC) so the CPU can talk to peripherals */
    intc@8000000 {
        compatible = "arm,gic-v3";
        interrupt-controller;
        #interrupt-cells = &amp;lt;0x03&amp;gt;;
        reg = &amp;lt;0x00 0x08000000 0x00 0x010000&amp;gt;,   /* GIC Distributor physical address */
              &amp;lt;0x00 0x080A0000 0x00 0xF60000&amp;gt;;   /* GIC Redistributors physical address */
        phandle = &amp;lt;0x8001&amp;gt;;
    };

    /* A Virtual UART (Serial Port) for console output */
    pl011@9000000 {
        compatible = "arm,pl011", "arm,primecell"; /* Tells Linux which driver binary to load */
        reg = &amp;lt;0x00 0x09000000 0x00 0x1000&amp;gt;;      /* Base address (0x09000000) and memory size (4KB) */
        interrupts = &amp;lt;0x00 0x01 0x04&amp;gt;;            /* Bound to SPI interrupt 1 */
        clocks = &amp;lt;0x8000&amp;gt;;                         /* Tells the guest which hardware interrupt wire it uses */
        clock-names = "uartclk";
    };

    /* ... PCI host bridge, virtio devices, and flash memory nodes continue below ... */
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the very top, QEMU defines the basic structure of our synthetic motherboard. It explicitly states that this is not a real-world chip from Raspberry Pi or Apple, but QEMU’s custom engine.&lt;br&gt;
Take a look at the pl011@9000000 block of the code at the end. This is a Virtual UART.  &lt;/p&gt;

&lt;p&gt;This is also why the &lt;code&gt;virt&lt;/code&gt; machine type matters on ARM64. Because it is not emulating any real-world board. It is a synthetic machine defined by QEMU. On x86, machine types like &lt;code&gt;pc&lt;/code&gt; and &lt;code&gt;q35&lt;/code&gt; emulate actual historical chipsets, complete with their quirks. ARM64 skipped that inheritance. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Dual-Engine Architecture of QEMU
&lt;/h2&gt;

&lt;p&gt;QEMU manages peripheral hardware. It has a device job. This should be clear to you by now. But, it also faces a separate challenge: how to execute the guest's CPU instructions. This is QEMU's CPU job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybd305dh6srjsqew3g3z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybd305dh6srjsqew3g3z.png" alt="Half 1 has two implementations. Half 2 has one, and always will." width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half 2 never changes.&lt;/strong&gt; We discussed in the previous article, that QEMU even without a KVM still able to virtualize. But, it is just slower. The peripheral devices are emulated by QEMU in userspace even if the KVM is missing or inactive. KVM has no built-in device models and never acquires any &lt;a href="https://dev.to/blog/kvm-vs-qemu-vs-libvirt-who-does-what/"&gt;previous article&lt;/a&gt;. (There is a minor exception: KVM implements a few latency-critical components, like the interrupt controller, inside the host kernel to boost performance—but the general rule stands, and that exception is simply an optimization worth remembering.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half 1 is the part with two implementations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCG — the Tiny Code Generator&lt;/strong&gt; — This is QEMU's software CPU. It reads blocks of guest instructions, compiles them into equivalent host instructions on the fly, caches the resulting binaries, and runs them. It is a Just-In-Time (JIT) compiler operating between two processor architectures. This is &lt;em&gt;true software emulation&lt;/em&gt;. And this is your only option when your guest and host architectures don't match. An x86 guest running on an ARM64 host must use TCG; no amount of hardware assistance can force an ARM core to natively parse x86 machine code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KVM&lt;/strong&gt; is the hardware path from &lt;a href="https://dev.to/blog/why-kvm-needs-the-cpu-el2-vhe-virtualization-extension/"&gt;article 1.2&lt;/a&gt;. Instead of translating instructions in software, QEMU asks KVM to orchestrate the execution. KVM runs the guest code directly on a real, physical core using hardware virtualization extensions. And after that KVM hands control back up to QEMU only when a device trap occurs.&lt;/p&gt;

&lt;p&gt;The choice between them is what &lt;code&gt;&amp;lt;domain type='kvm'&amp;gt;&lt;/code&gt; versus &lt;code&gt;&amp;lt;domain type='qemu'&amp;gt;&lt;/code&gt; selects in libvirt's XML, and it's the single biggest performance factor in the entire stack. We will learn more about these in future posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consequence: what a device access costs
&lt;/h2&gt;

&lt;p&gt;When you combine QEMU's two halves, the central performance reality of all virtualization instantly falls into right place.&lt;/p&gt;

&lt;p&gt;Under KVM, guest code runs natively at full speed. Arithmetic, conditional loops, and memory operations inside actual RAM happen directly on the bare metal without any intervention from KVM or QEMU. Millions of instructions can execute back-to-back without either host component ever waking up.&lt;/p&gt;

&lt;p&gt;However, a device access can't complete natively, because there's no physical device. It has no choice but to trap out to QEMU's C code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgaosd8g5cas5g93bklhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgaosd8g5cas5g93bklhe.png" alt="Computation stays in the band at native speed. Every device access leaves it and comes back." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the cost model of a virtual machine is: &lt;strong&gt;native speed for computation, and a relatively expensive round trip for every device interaction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This single asymmetry explains an enormous amount of real-world behavior. If you have ever worked with VMs you will be able to relate immediately. It is the exact reason why CPU-bound workloads in a VM perform at near-bare-metal speeds while I/O-heavy workloads lag. It is why a virtual serial console is perfectly fine for login shell. Because a human types slowly. So a trap per character goes unnoticed. However, it would be absolutely catastrophic for a virtual network card processing a hundred thousand packets per second.&lt;/p&gt;

&lt;p&gt;And it's the reason VirtIO exists. If device access is the expensive operation, the winning strategy is to make each one carry more work. In other words: you batch. That's &lt;a href="https://dev.to/blog/why-virtio-is-faster-virtqueues-and-vhost-net-explained/"&gt;article 6&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But you would understand the batching argument only if you know what the round trip actually involves: what "control leaves the guest" means mechanically, what data crosses the boundary, and why some traps are far cheaper than others. That's the next article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qemu-system-*&lt;/code&gt; emulates a whole machine and boots an OS. &lt;code&gt;qemu-&amp;lt;arch&amp;gt;&lt;/code&gt; runs a single foreign binary. Only the first is virtualization.&lt;/li&gt;
&lt;li&gt;Devices on real hardware are reached through &lt;strong&gt;memory-mapped I/O&lt;/strong&gt; — the CPU writes to addresses that are wired to chips rather than RAM.&lt;/li&gt;
&lt;li&gt;QEMU exploits this by marking regions of the guest address space as device regions. An access there traps and runs a C function. That function &lt;em&gt;is&lt;/em&gt; the device.&lt;/li&gt;
&lt;li&gt;On ARM64, the guest discovers what exists and where via a &lt;strong&gt;device tree&lt;/strong&gt; QEMU generates. Guest drivers are the standard, unmodified Linux drivers.&lt;/li&gt;
&lt;li&gt;QEMU has two halves. Devices are always emulated in QEMU userspace. The CPU is either translated in software (&lt;strong&gt;TCG&lt;/strong&gt;) or run natively on real hardware (&lt;strong&gt;KVM&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;Under KVM, computation runs at native speed but every device access costs a trap out to QEMU. That asymmetry is the foundation of every performance decision in virtualization.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>kvm</category>
      <category>linux</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>KVM vs QEMU vs libvirt: Who Does What</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:30:00 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/kvm-vs-qemu-vs-libvirt-who-does-what-3jl4</link>
      <guid>https://dev.to/ranaweerasupun/kvm-vs-qemu-vs-libvirt-who-does-what-3jl4</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm"&gt;previous article&lt;/a&gt; covered the CPU virtualization extension and how KVM drives it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"What’s the difference between KVM and QEMU?" is one of the most-asked questions in Linux virtualization. If you look around most answers just stack definitions on top of each other. For me, definitions are easily forgotten. I think the best way is to build the causal chain—understanding that each component exists specifically to solve a limitation left behind by the layer beneath it. So, rather than defining everything upfront and hoping you connect the dots, I will start with KVM’s deliberate boundaries and follows the architectural consequences all the way up.&lt;/p&gt;

&lt;h2&gt;
  
  
  KVM only virtualizes the CPU and Memory. Nothing else.
&lt;/h2&gt;

&lt;p&gt;The previous article established what KVM(Kernel-based Virtual Machine) contributes. KVM drives the CPU's virtualization extension so guest code executes natively on real cores, trapping only on privileged operations. It also manages guest memory by mapping the guest's idea of physical addresses onto host pages, and keeping each guest confined to its own.&lt;/p&gt;

&lt;p&gt;KVM is incredibly efficient at executing CPU instructions and managing guest memory at near-native speed. But it only virtualizes the CPU and Memory. Nothing else.&lt;/p&gt;

&lt;p&gt;A naked CPU and raw RAM do not make a computer. A virtual machine needs a motherboard, a PCI bus, a keyboard, a mouse, a graphics card, serial ports, and disk controllers. And KVM deliberately includes none of these hardware simulations. So, if you only had KVM, your virtual machine couldn't boot because it wouldn't have a virtual hard drive to read an OS from, or a virtual screen to display it.&lt;/p&gt;

&lt;p&gt;You need to keep this in mind. This is NOT an oversight. It is a design decision, and &lt;a href="https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0"&gt;article 1.1&lt;/a&gt; explained why: because the hypervisor lives inside a full Linux kernel, it inherits everything Linux already does well. Building device emulation into KVM would mean duplicating work the kernel and userspace already handle.&lt;/p&gt;

&lt;p&gt;So KVM leaves an obvious gap. A guest kernel boots, probes for a disk, and finds nothing. We need something that can answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltyio0lepggmnscfza9g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltyio0lepggmnscfza9g.png" alt="1–3–1-what-kvm-leaves-out" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  QEMU Fills the Hardware Gap: It pretends to be all the devices
&lt;/h2&gt;

&lt;p&gt;Because KVM leaves the virtual machine without a "body," it needs a partner. QEMU(Quick Emulator) is a userspace program that emulates an entire machine's worth of devices. When the guest probes for a disk controller, QEMU responds. When the guest writes a byte to a serial port, QEMU receives it. When the guest sends a network packet, QEMU hands it to the host's network stack.&lt;/p&gt;

&lt;p&gt;This reveals the exact handoff between KVM and QEMU. When the guest tries to talk to a hardware device, KVM intercepts the request because KVM doesn't know how to handle hardware. KVM pauses the VM and hands the request up to QEMU. Inside QEMU, a piece of C code pretends to be the chip the guest is looking for, handles the data, and tells KVM to let the guest resume. Every piece of "hardware" the guest sees is just an illusion written in software. &lt;a href="https://bittobyteacademy.com/blog/how-qemu-emulates-hardware-device-models-mmio-tcg/" rel="noopener noreferrer"&gt;Article 1.4&lt;/a&gt; covers how that actually works, since "emulates devices" is doing a lot of quiet work in that sentence.&lt;/p&gt;

&lt;p&gt;QEMU can also emulate a CPU. On its own it's a complete machine emulator. So, even if you don't give it a KVM, it will translate guest instructions in software and boot an OS anyway, slowly. But if you pair it with a KVM, it hands the CPU and memory job to KVM and keeps only the devices.&lt;/p&gt;

&lt;p&gt;That pairing is what a running VM is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ndg7rbflaydik8axki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx4ndg7rbflaydik8axki.png" alt="1–3–2-how-qemu-and-kvm-split-a-vm" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This architecture leads to two structural realities that govern how virtual machines behave on a live Linux system:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One VM is one QEMU process.&lt;/strong&gt; A virtual machine is not a mysterious container or a cluster of workers. It is a single, standard Linux process (like qemu-system-x86_64 or qemu-system-aarch64) running in user space. &lt;code&gt;qemu-system-x86_64&lt;/code&gt; and &lt;code&gt;qemu-system-aarch64&lt;/code&gt; are examples. &lt;code&gt;qemu-system-x86_64&lt;/code&gt; process emulates a 64-bit Intel / AMD (x86_64) architecture and &lt;code&gt;qemu-system-aarch64&lt;/code&gt; emulates 64-bit ARM (ARM64 / AArch64) architecture. They are executable programs that run as processes on your operating system. So, in your task manager (like top or htop on Linux/macOS, or Task Manager on Windows), you will see qemu-system-x86_64 or qemu-system-aarch64 listed as an active process. If you kill that process, the VM instantly dies. If a guest has four vCPUs, that single process simply spins up four ordinary Linux threads. Each thread runs a continuous loop: execute guest code, halt when something traps, handle the intercept, and resume. Because these are standard host threads, the Linux scheduler manages them normally, which is exactly why you can pin specific vCPUs to physical CPU cores using standard Linux tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two halves exist in a constant state of handoff&lt;/strong&gt; Because KVM only handles CPU and memory, it runs guest code at native hardware speed right up until the guest tries to interact with a device (like writing to a disk or sending a network packet). KVM cannot handle this, so it pauses the vCPU thread and yields control back up to QEMU. QEMU’s C-coded device model &lt;em&gt;simulates&lt;/em&gt; the hardware response, updates the virtual state, and tells KVM to resume execution. That handoff is &lt;a href="https://bittobyteacademy.com/blog/what-ioctl-kvm-run-does-the-vm-exit-loop-explained/" rel="noopener noreferrer"&gt;article 1.5&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So KVM and QEMU together are a complete machine. Which raises the next question: how do you actually start one?&lt;/p&gt;

&lt;h2&gt;
  
  
  libvirt: Turning a Messy Command Into a Permanent Document
&lt;/h2&gt;

&lt;p&gt;There is the problem with QEMU as a user interface. Describing a virtual machine to it means you have to to pass every single hardware detail as a raw command-line flag. &lt;/p&gt;

&lt;p&gt;To launch a standard VM, you have to manually specify each disk's format and bus type, every network interface's model and host backend, the exact firmware paths, the CPU microarchitecture, the serial console configurations, and the explicit memory layout. A realistic, production-ready QEMU invocation easily runs to dozens of flags across multiple lines of unreadable text.&lt;/p&gt;

&lt;p&gt;Nobody wants to type that. More importantly, nobody wants to remember it.&lt;/p&gt;

&lt;p&gt;Libvirt solves this by making the VM a document rather than a command.&lt;/p&gt;

&lt;p&gt;Instead of writing a transient shell script, you describe the machine’s desired state once in a structured XML file. The Libvirt daemon (libvirtd) reads that XML, &lt;em&gt;dynamically&lt;/em&gt; constructs the corresponding, massive QEMU command line behind the scenes, launches the process, and tracks its lifecycle from that moment forward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsb7tqavgtzxqzmrjdq37.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsb7tqavgtzxqzmrjdq37.png" alt="1–3–3-how-libvirt-starts-a-vm" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Scope: Managing the Ecosystem
&lt;/h2&gt;

&lt;p&gt;libvirt's scope is broader than launching processes. It defines and manages virtual networks, storage pools, and snapshots. It exposes a stable API, so tooling written against libvirt keeps working across QEMU versions. And it maintains a control channel to each running QEMU — the QMP socket — which is how a running VM can have a disk hot-plugged or a snapshot taken without restarting.&lt;/p&gt;

&lt;p&gt;The tools you type are all front-ends to that daemon. &lt;code&gt;virsh&lt;/code&gt; is the command-line client and the one this series lives in. &lt;code&gt;virt-manager&lt;/code&gt; is a desktop GUI. &lt;code&gt;virt-install&lt;/code&gt; creates new VMs. Cockpit provides a web interface. None of them talk to QEMU directly — they all send API calls to &lt;code&gt;libvirtd&lt;/code&gt;, which does the work.&lt;/p&gt;

&lt;p&gt;The payoff is that &lt;strong&gt;the XML is the VM.&lt;/strong&gt; There's no hidden state elsewhere. Change a line in the document and you've changed the machine. That's why the domain XML is the central artifact of libvirt work, and it's the subject that opens the next phase of this series.&lt;/p&gt;

&lt;p&gt;Libvirt’s responsibility doesn't end once the QEMU process is running. Its scope is much broader than launching processes. Libvirt defines and manages the entire virtualization ecosystem, including virtual networks, storage pools, and snapshots.&lt;/p&gt;

&lt;p&gt;By exposing a stable, unified API, Libvirt ensures that any tooling written against it keeps working seamlessly across different QEMU versions. This shields you from breaking changes in upstream software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Frontends: Just Messengers for the Daemon
&lt;/h2&gt;

&lt;p&gt;Because Libvirt acts as the central brain, the tools you actually type or click are just thin frontends to the daemon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;virsh&lt;/code&gt; – The powerful command-line client (and the core focus of this series).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virt-manager&lt;/code&gt; – A traditional desktop GUI for visual management.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virt-install&lt;/code&gt; – A specialized CLI tool dedicated to provisioning new VMs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cockpit&lt;/code&gt; – A modern, browser-based web interface for server management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these tools talk to QEMU directly. They all send standard API calls to libvirtd, the Libvirt daemon. It executes the heavy lifting behind the scenes. &lt;/p&gt;

&lt;p&gt;The ultimate payoff of this architecture is simple: &lt;strong&gt;the XML is the VM&lt;/strong&gt;. There's no hidden state elsewhere. If you change a line in that document, you change the machine. Because the domain XML is the absolute central artifact of Libvirt, it is the exact subject that will open the next phase of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three sentences
&lt;/h2&gt;

&lt;p&gt;Compressed to their essentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;KVM&lt;/strong&gt; runs the guest's CPU and memory on real hardware at native speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QEMU&lt;/strong&gt; pretends to be all the devices the guest thinks it has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libvirt&lt;/strong&gt; stores your VM as XML and drives QEMU so you don't have to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Definitions are handy, but the causal version is far more useful because it explains exactly why the Linux virtualization stack has this specific shape:&lt;/p&gt;

&lt;p&gt;KVM is lightweight because Linux already provides everything except raw CPU virtualization. That leaves peripheral hardware unhandled. So, QEMU steps in to simulate devices in userspace. However, QEMU’s resulting command-line interface is completely unusable by hand, so Libvirt abstracts it into a clean, permanent document and manages it for you.&lt;/p&gt;

&lt;p&gt;If you want to test your understanding of this stack, imagine pulling a single brick out of the wall. And see what happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Without Libvirt: Everything still runs fast and has hardware, but you are stuck hand-writing, troubleshooting, and maintaining enormous, brittle command lines.&lt;/li&gt;
&lt;li&gt;Without QEMU: Your virtual machine has a hyper-fast brain but no body. The guest has no virtual motherboard, network card, or disk to boot from.&lt;/li&gt;
&lt;li&gt;Without KVM: Everything still works perfectly, and your guest has all its devices. But, it just runs painfully slow completely in software emulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means for the rest of the series
&lt;/h2&gt;

&lt;p&gt;The division of labour is a map for everything that follows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage&lt;/strong&gt; work — disk formats, snapshots, thin provisioning — is about what QEMU's virtual disk points at on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Networking&lt;/strong&gt; work is about what QEMU's virtual NIC plugs into: a NAT bridge, a LAN bridge, a TAP device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt; work splits across the boundary. Replacing emulated devices with VirtIO ones is QEMU-side. Pinning vCPU threads to physical cores is host-scheduler-side, and possible only because those threads are ordinary Linux threads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passthrough&lt;/strong&gt; is the exception that proves the rule: it bypasses QEMU's emulation entirely and hands a guest real hardware.&lt;/p&gt;

&lt;p&gt;Every one of those is a modification to one part of a machine whose shape you now know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;KVM handles guest CPU and memory only. It has no device emulation at all, by design, because Linux already provides everything else.&lt;/li&gt;
&lt;li&gt;QEMU fills that gap: a userspace program that emulates disks, NICs, consoles, and firmware. It can emulate a CPU too, but hands that job to KVM when available.&lt;/li&gt;
&lt;li&gt;One running VM is one QEMU process, with one host thread per vCPU.&lt;/li&gt;
&lt;li&gt;libvirt exists because QEMU's command line is impractical to write by hand. It stores each VM as XML, builds the command line, launches QEMU, and manages it via a control socket.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;virsh&lt;/code&gt;, &lt;code&gt;virt-manager&lt;/code&gt;, &lt;code&gt;virt-install&lt;/code&gt;, and Cockpit are all front-ends to the &lt;code&gt;libvirtd&lt;/code&gt; daemon.&lt;/li&gt;
&lt;li&gt;The domain XML &lt;em&gt;is&lt;/em&gt; the VM — there's no hidden state elsewhere.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>devops</category>
      <category>linux</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>Why KVM Needs the CPU: ARM64 EL2, VHE, and the Virtualization Extension</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 01 Sep 2026 09:06:04 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm</link>
      <guid>https://dev.to/ranaweerasupun/why-kvm-needs-the-cpu-arm64-el2-vhe-and-the-virtualization-extension-6hm</guid>
      <description>&lt;p&gt;&lt;em&gt;The &lt;a href="https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0"&gt;previous article&lt;/a&gt; established that KVM is a kernel module that turns the running Linux kernel into a hypervisor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That description is accurate but incomplete, and the gap is where a lot of otherwise-solid mental models come apart. A kernel can't simply decide to run guest operating systems at native speed. It needs the CPU's cooperation — a hardware feature that sits dormant in the silicon until software switches it on.&lt;/p&gt;

&lt;p&gt;This article is about that feature: what problem it solves, what it looks like on ARM64, and how it relates to the kernel module. The relationship between those last two is the part worth getting right, because "is virtualization hardware or software?" has an answer that isn't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a guest kernel wants to run privileged instructions
&lt;/h2&gt;

&lt;p&gt;Recall the lie from the previous article; "every guest OS believes it owns the hardware". That belief has teeth. A kernel doesn't just &lt;em&gt;think&lt;/em&gt; it's in charge; it executes instructions that only something in charge is allowed to execute. It reconfigures page tables. It writes to device registers. It masks and unmasks interrupts. It sets up the memory management unit.&lt;/p&gt;

&lt;p&gt;Now put that kernel in a VM. It's going to attempt all of those things, because that's what kernels do at boot. Two options present themselves, and both are bad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: let it run them on the real CPU.&lt;/strong&gt; The guest reconfigures the actual MMU, writes to actual device registers, and takes down the host and every other guest with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: catch every instruction in software.&lt;/strong&gt; Inspect each one before it executes, and when it's privileged, emulate its effect against fake state instead of letting it touch hardware. This works. And it is how software-only virtualization operates. However, the cost is enormous. Software is now involved in &lt;em&gt;every single instruction the guest runs&lt;/em&gt;, including the overwhelming majority that are perfectly harmless arithmetic and branches.&lt;/p&gt;

&lt;p&gt;The insight that makes modern virtualization viable is that neither option is necessary if the CPU itself can tell the difference.&lt;/p&gt;

&lt;p&gt;But, think about it for a second. What if the CPU can tell the difference? &lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is in the silicon
&lt;/h2&gt;

&lt;p&gt;Modern CPUs ship with a &lt;strong&gt;virtualization extension&lt;/strong&gt;: a hardware feature that adds a privilege level above the normal kernel level, designed specifically for a hypervisor to occupy.&lt;/p&gt;

&lt;p&gt;With it, the guest kernel runs at its usual privilege level, fully believing it's in charge. Its ordinary instructions — the arithmetic, the branches, the memory accesses — execute directly on a real core at full speed, with no software involvement whatsoever. But the moment it attempts something that would affect real hardware, the CPU &lt;strong&gt;traps&lt;/strong&gt;. It freezes the guest and transfers control up to the hypervisor.&lt;/p&gt;

&lt;p&gt;This is the crucial economic property. Now the expensive interventions happen only on privileged operations, which are rare. Everything else runs native. The software now can stop babysitting and start handling exceptions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7e0qa5qfz1jfy8kq4a0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7e0qa5qfz1jfy8kq4a0.png" alt="kvm-virtualization-on-arm64-software-emulation-vs-hardware-trap" width="799" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That trap is the &lt;strong&gt;VM exit&lt;/strong&gt;. A VM Exit is a hardware-driven transition where control shifts from the guest virtual machine back to the host operating system hypervisor. This article establishes what causes a VM exit and why it exists; the mechanics of what happens during one — how control actually returns to userspace, what data crosses the boundary, and why some exits are far cheaper than others — are the subject of &lt;a href="https://dev.to/blog/what-ioctl-kvm-run-does-the-vm-exit-loop-explained/"&gt;article 5&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ARM64's privilege levels, and the x86 equivalents
&lt;/h2&gt;

&lt;p&gt;ARM calls its privilege levels &lt;strong&gt;Exception Levels&lt;/strong&gt;, abbreviated EL. Higher numbers mean more privilege, which is the opposite of x86's ring numbering.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuxe1sn7ydqfc5v5gh0yo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuxe1sn7ydqfc5v5gh0yo.png" alt="kvm-virtualization-on-arm64-arm64-exception-levels-vs-x86-rings" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A guest's applications run at EL0. The guest kernel runs at EL1, where it has always expected to run, and where it can do everything a kernel normally does. Above it sits &lt;strong&gt;EL2&lt;/strong&gt;, the hypervisor level, which the guest cannot see or reach.&lt;/p&gt;

&lt;p&gt;On x86 the same concept is called VT-x on Intel and AMD-V on AMD, and the additional level is described as "root mode" rather than given a number. The naming differs but the structure is the same. If you know x86 virtualization, EL2 is your root mode.&lt;/p&gt;

&lt;p&gt;There's a fourth level on ARM — EL3, for secure-world firmware. This is outside the scope of virtualization and not something KVM touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  VHE: why EL2 alone wasn't enough
&lt;/h2&gt;

&lt;p&gt;The original ARM virtualization design assumed the hypervisor would be a small, purpose-built piece of software living at EL2. EL2 was designed for that: a lean environment with its own register set, deliberately unlike EL1.&lt;/p&gt;

&lt;p&gt;Linux is not that. Linux is a large general-purpose kernel written to run at EL1, and KVM is a module inside it. Under the original design, running KVM meant splitting the kernel. So most of Linux will be at EL1, with a small stub at EL2 handling world switches. It worked, but every transition between the two carried overhead. And this split was structurally unpleasant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VHE — Virtualization Host Extensions&lt;/strong&gt; — fixed this. Introduced in ARMv8.1, VHE makes EL2 capable of running an ordinary EL1-style kernel directly, by remapping registers so that a kernel written for EL1 can execute at EL2 without modification. The host kernel, KVM included can run &lt;em&gt;entirely&lt;/em&gt; at EL2. Guests run at EL1 below it. Neither split nor stub is needed now.&lt;/p&gt;

&lt;p&gt;The practical upshot: on VHE-capable hardware, KVM runs in its efficient configuration. On older ARM cores without it, KVM still works, using the split approach. The Cortex-A76 in the Raspberry Pi 5 supports VHE. If we check the kernel boot log we can confirm this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it on real hardware
&lt;/h2&gt;

&lt;p&gt;Here's the boot log from a Raspberry Pi 5 running Raspberry Pi OS Bookworm, kernel 6.12 aarch64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; kvm
&lt;span class="go"&gt;[    0.046684] kvm [1]: nv: 554 coarse grained trap handlers
[    0.046799] kvm [1]: IPA Size Limit: 40 bits
[    0.046811] kvm [1]: GICV region size/alignment is unsafe, using trapping (reduced performance)
[    0.046835] kvm [1]: vgic interrupt IRQ9
[    0.046846] kvm [1]: VHE mode initialized successfully
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every concept in this article is visible in those five lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;VHE mode initialized successfully&lt;/code&gt;&lt;/strong&gt; It confirms not just that KVM found a usable virtualization extension, but that it's running in the modern configuration described above. The host kernel is at EL2 without a split. This is the exact moment the dormant silicon feature was switched on, recorded in the boot log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;IPA Size Limit: 40 bits&lt;/code&gt;&lt;/strong&gt; refers to the Intermediate Physical Address space. This is the guest's view of physical memory. When a guest accesses what it believes is a physical address, that's an IPA(Intermediate Physical Address), which hardware then translates to a real host physical address. A 40-bit tracking system can create (2^{40}) unique address combinations(which is 1 Terabyte (TB) of RAM). The hardware allows the virtual machine to hold drastically more memory than your physical computer actually possesses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;vgic interrupt IRQ9&lt;/code&gt;&lt;/strong&gt; GIC (Generic Interrupt Controller): This is the physical ARM hardware chip responsible for delivering interrupts from hardware devices directly to the CPU. Guests need interrupts too. Because multiple virtual machines cannot safely share the same physical interrupt chip, KVM creates a simulated, virtual version (Virtual GIC) for each VM.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;GICV region size/alignment is unsafe, using trapping (reduced performance)&lt;/code&gt; line is a Raspberry Pi–specific quirk. One optimization for injecting interrupts directly into guests isn't safely available on this hardware, so KVM falls back to trapping them. It does not affect whether acceleration works. it makes one specific interrupt path slightly slower, that is all. This is a physical trait of the Raspberry Pi chip that cannot be changed. So we ignore this warning and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nv: 554 coarse grained trap handlers&lt;/code&gt;&lt;/strong&gt; refers to nested virtualization support — running a hypervisor inside a guest. Not relevant to in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is virtualization silicon or software?
&lt;/h2&gt;

&lt;p&gt;Now we can take on the question this article exists to answer. If virtualization is a CPU feature, why is KVM a kernel module? And if KVM is a kernel module, what is the CPU actually contributing?&lt;/p&gt;

&lt;p&gt;They are two halves of one mechanism, and neither accomplishes anything alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CPU extension is a dormant hardware capability.&lt;/strong&gt; EL2 exists in the Cortex-A76 the moment it leaves the factory. It is baked into the silicon and does nothing on its own. It's a feature waiting for software to claim it. Is is basically an engine with no driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KVM module is the software that claims and drives it.&lt;/strong&gt; When KVM initializes, this is the sequence:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjtnci0z3q3bpowxf7v7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzjtnci0z3q3bpowxf7v7.png" alt="kvm-virtualization-on-arm64-kvm-init-extension-check" width="799" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the answer to "is virtualization hardware or software" is that it's a partnership with a strict dependency. The extension without KVM is an unused silicon feature. KVM without the extension has no job. It exists solely to drive that hardware, and it will refuse to initialize on a CPU that lacks it.&lt;/p&gt;

&lt;p&gt;This dependency has a useful consequence: &lt;strong&gt;the existence of &lt;code&gt;/dev/kvm&lt;/code&gt; is proof the whole chain worked.&lt;/strong&gt; Beacause, that file is created only when KVM has successfully initialized against a real virtualization extension. If it is there, the silicon feature is present, the software found it, and the door is open. That's why every KVM troubleshooting guide starts by checking for it, and it's the first command in &lt;a href="https://dev.to/blog/how-to-check-kvm-is-enabled-on-raspberry-pi-5/"&gt;article 7&lt;/a&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the extension is absent
&lt;/h2&gt;

&lt;p&gt;This is worth stating explicitly, because it clarifies the boundary. If a CPU has no virtualization extension, you are not stuck. You can virtualize but you're just slow.&lt;/p&gt;

&lt;p&gt;QEMU(Quick Emulator) is a complete machine emulator independent of KVM. It is a software program that emulates and virtualizes computers.&lt;/p&gt;

&lt;p&gt;QEMU can mimic an entire computer hardware system in software. It lets you run an operating system made for one processor (like an ARM chip) on a completely different processor (like an Intel laptop chip) by translating the code step-by-step instruction by instruction. And QEMU can do this without any hardware support. It works. It's simply far slower and you can guess why. Because software is translating guest instructions rather than letting them run on a real core. &lt;/p&gt;

&lt;p&gt;But, if you pair QEMU with a kernel module like KVM, it drops the slow software translation. Instead, it passes instructions directly to your real CPU cores, letting the virtual machine run at near-native hardware speed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   QEMU alone   = full machine in software    → works, slow
   QEMU + KVM   = QEMU does devices,          → works, fast
                  KVM rides the CPU extension
                  for CPU and memory
   KVM alone    = impossible — KVM only does CPU and memory.
                  It can't be a disk or a NIC. It always needs QEMU.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third line is the setup for the next article, and there's a consequence of the second because it's specific to ARM64 hosts.&lt;/p&gt;

&lt;p&gt;KVM can only accelerate guests whose instruction set matches the host's. An &lt;strong&gt;aarch64 guest on an ARM64 host&lt;/strong&gt; runs natively. The Cortex-A76(like in a Raspberry pi 5) executes the guest's instructions directly, so KVM applies and it's fast. An &lt;strong&gt;x86 guest on the same host&lt;/strong&gt; cannot. Because, those aren't ARM instructions, and no extension makes a real ARM core execute them. QEMU falls back to full software translation.&lt;/p&gt;

&lt;p&gt;This isn't a limitation of KVM or of the Raspberry Pi. It's the boundary of what hardware acceleration means. Acceleration requires the guest and host architectures to match; when they don't, you're emulating, and emulation is slow. Anything in this series involving KVM assumes aarch64 guests.&lt;/p&gt;

&lt;p&gt;Remember: Hardware acceleration always requires the guest and host architectures to match, regardless of whether you are using ARM or x86.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A guest kernel executes privileged instructions. Letting them touch real hardware is unsafe; catching every one in software is slow.&lt;/li&gt;
&lt;li&gt;CPU virtualization extensions solve this by adding a privilege level above the kernel. Guest code runs natively until it does something privileged, which traps to the hypervisor. That trap is a VM exit.&lt;/li&gt;
&lt;li&gt;On ARM64 the hypervisor level is &lt;strong&gt;EL2&lt;/strong&gt;, above EL1 (kernel) and EL0 (user). The x86 equivalent is VT-x/AMD-V root mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VHE&lt;/strong&gt; lets a full Linux kernel run at EL2 unmodified, which is the efficient configuration KVM uses on modern ARM cores. &lt;code&gt;dmesg&lt;/code&gt; reports it at boot.&lt;/li&gt;
&lt;li&gt;The silicon extension and the KVM module are partners: the extension is dormant hardware, KVM is the software that switches it on. Neither works alone, and KVM refuses to initialize without it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/dev/kvm&lt;/code&gt; existing is proof the partnership succeeded.&lt;/li&gt;
&lt;li&gt;KVM only accelerates guests matching the host architecture. x86 guests on ARM64 fall back to slow software emulation.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>linux</category>
      <category>virtualization</category>
      <category>arm64</category>
    </item>
    <item>
      <title>I smashed a bug so hard and came out with a Python library</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:01:47 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/i-smashed-a-bug-so-hard-and-came-out-with-a-python-library-1fpc</link>
      <guid>https://dev.to/ranaweerasupun/i-smashed-a-bug-so-hard-and-came-out-with-a-python-library-1fpc</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the story of my bug spray.&lt;/p&gt;

&lt;p&gt;I build sensor systems that live outdoors. Things like Raspberry Pi units on 4G cellular, embedded gateways, battery monitoring hardware in places nobody visits twice a year. On those networks, connection reliability isn't a simple nice-to-have.&lt;/p&gt;

&lt;p&gt;I was a junior back then, and I lost data for weeks before I understood why. When I finally went looking for answers, all I found were scattered fixes. Someone had commented on a years-old thread, "this worked for me". Without any consistency anywhere.&lt;/p&gt;

&lt;p&gt;So I patched. Patch after patch, bug after bug, year after year. At some point I thought: why not smash this thing once and for all, for everyone?&lt;/p&gt;

&lt;p&gt;And so I did. Smashed, and smashed, and smashed. The final result? An open source python library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The device that went deaf
&lt;/h2&gt;

&lt;p&gt;Let me tell you about the bug first. So that you know what I smashed and you don't feel pity for it.&lt;/p&gt;

&lt;p&gt;Picture a busy restaurant. You're working the pass out front; the kitchen is through the back, and the two of you talk over a radio link. Every thirty seconds the kitchen shouts, "All dishes on time!" Then the food stops coming out. You check the channel, the power light and the microphone. And Everything is perfect. The kitchen keeps cheerfully reporting they're doing great, and ignores every order you scream back.(Sounds annoying, right?)&lt;/p&gt;

&lt;p&gt;What happened is that the signal hissed for half a second earlier that night. The link dropped and came back instantly. But the radio system had been set up with a rule: when a link drops, wipe everything about it. The kitchen's standing request to be told about incoming orders was part of that everything. So it went ahead and wiped everything!. The kitchen isn't muted and isn't broken. Its request simply isn't on the list anymore, and it has no way of knowing that. So there are no warining lights or warning beeps beacuse there is no failure to report, since nothing failed.&lt;/p&gt;

&lt;p&gt;The system expects the kitchen to re-announce "we're listening on channel four" itself, every single reconnect, forever, unprompted. If it doesn't, it wakes up half alive: still broadcasting to the dining room, deaf to every order coming in. And the only channel it has for telling you something is wrong is the half that still works.&lt;/p&gt;

&lt;p&gt;The radio wasn't defective. It did exactly what it had been told to do. And who was the person told it? Me!. I told it months earlier, when I set it up. It was just never designed for a kitchen four hours away with nobody standing next to it.&lt;/p&gt;

&lt;p&gt;MQTT is a simple messaging system used by smart gadgets to talk to servers, acting like a two-way radio channel where devices must specifically "subscribe" to a frequency to hear messages. By default, if a device loses its connection even for a second, the system resets and forgets what channel it was listening to, requiring the device to manually tune back in. Paho MQTT is an open-source library that gives you this MQTT messaging protocol.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jpze9fim6n1ybktdncp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7jpze9fim6n1ybktdncp.png" alt="the-bug-from-mqtt" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Once I started looking, it wasn't the only bug. A bug spray is required!
&lt;/h2&gt;

&lt;p&gt;The deaf device showed me a buch of other bugs and issues with this whole thing. &lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Reconnection is your problem, not the library's.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;paho&lt;/code&gt; has &lt;code&gt;reconnect_delay_set&lt;/code&gt;, but only &lt;code&gt;loop_forever()&lt;/code&gt; honours it. With &lt;code&gt;loop_start()&lt;/code&gt; — which is what you want the moment the client isn't the main thread — you're writing the retry loop yourself. I wrote the naive one. Fixed a five-second retry, like everybody's first attempt.&lt;/p&gt;

&lt;p&gt;Now, having fixed the reconnection bug, I wanted to try it on a device. It worked. I was happy. Wasn't so hard, I thought. So a while later I proudly rolled it out to ten devices.&lt;/p&gt;

&lt;p&gt;It died right away. Ten devices, one outage, and every one of them waiting exactly five seconds before knocking again — in perfect unison, forever. The broker came up, took ten simultaneous connections in the same instant, and went straight back down. I waited. It never resurrected itself. It couldn't: each restart just called the stampede back.&lt;/p&gt;

&lt;p&gt;Ten devices were enough to demostrate this bug. So, yes. I fell to a bug from a bug. The fix was exponential backoff with a jitter, so they don't re-synchronise. This is the difference between recovery and a self-inflicted DoS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#Backoff needs a cap and jitter.
# And without the jitter, ten devices that dropped together retry together. That's what killed my broker.
&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# cap: never wait over 5 min
&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# jitter: don't come back in unison
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;The bug came back!&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Smash a bug hard enough and it comes back as a phoenix. Karma, probably. My new backoff loop retried forever, which is what I wanted, because a device four hours away has to keep trying. But it had no idea why a connection ended. So when I hit Ctrl-C, my shutdown handler closed the connection, my own retry loop saw a dead connection, and it immediately opened a new one. The process wouldn't exit. I had made it unkillable by making it reliable.&lt;/p&gt;

&lt;p&gt;There is a simple fix, though. When paho tells you the connection dropped, it hands you a number. Zero means you closed it yourself. Anything else means it broke. Only reconnect on "anything else". Nothing does this for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#The bug: any disconnect triggers a reconnect.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# including the disconnect you asked for
&lt;/span&gt;
&lt;span class="c1"&gt;#The fix is one number.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_disconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;        &lt;span class="c1"&gt;# you closed it. leave it closed.
&lt;/span&gt;    &lt;span class="nf"&gt;schedule_reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;The first connection is a different path, and it usually isn't retried at all.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;All that backoff logic I just wrote lives in &lt;code&gt;on_disconnect&lt;/code&gt; — the callback that fires when a connection we already had goes away. But a device that boots before its 4G modem is ready never had one. &lt;code&gt;connect()&lt;/code&gt; raises, &lt;code&gt;main()&lt;/code&gt; dies. Because the modem is not ready to communicate. And the process exits before any of my retry code ever runs. When systemd restarts the service if the modem still isn't up, it dies again. That's not a retry loop I designed; it's just a process crashing over and over.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;And the final boss, which is the worst: messages just vanish.&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;My five-minute ceiling had a consequence I hadn't thought about. After a bad drop, the device deliberately just sits there doing nothing for up to five minutes. Which means every reading it takes during that window has nowhere to go. &lt;code&gt;publish()&lt;/code&gt; doesn't complain about this. It hands back a result code saying the message never left. But that is all. Paho can queue messages for you, but that queue lives in memory, so it dies with the process. A power cycle in the field takes your data with it. Gone.&lt;/p&gt;

&lt;p&gt;I tried a few of my own patches over the years for this without ever fixing it properly. Sensor data is the entire reason the device is out there. That's when I stopped patching and started writing a queue that saves messages to disk, so they survive a restart.&lt;/p&gt;

&lt;p&gt;All these four different failure modes had one thing in common: all of them fail &lt;em&gt;quietly&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I made the thing I'd been looking for: The bug spray!
&lt;/h2&gt;

&lt;p&gt;Everything above became &lt;a href="https://pypi.org/project/robmqtt/" rel="noopener noreferrer"&gt;&lt;code&gt;robmqtt&lt;/code&gt;&lt;/a&gt; — a resilient MQTT client for edge devices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline queue in SQLite&lt;/strong&gt; — unreachable broker means messages get written to disk, not dropped. They survive restarts and power cycles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inflight tracking&lt;/strong&gt; — sent-but-unacknowledged messages are tracked separately and re-sent on reconnect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority eviction&lt;/strong&gt; — when the queue fills, low-priority telemetry goes before critical alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff&lt;/strong&gt; — no stampede.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription registry&lt;/strong&gt; — subscriptions are restored automatically on every reconnect. No &lt;code&gt;on_connect&lt;/code&gt; bookkeeping, no deaf devices. It also means you can call &lt;code&gt;subscribe()&lt;/code&gt; before &lt;code&gt;connect()&lt;/code&gt;, which is how it should have worked in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured logging throughout&lt;/strong&gt; — so the next person doesn't lose days to a problem that leaves no trace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application code never has to know whether the broker is reachable. That was the entire goal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;robmqtt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ProductionMQTTClient&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProductionMQTTClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field_device_001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mqtt.yourdomain.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_queue_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./device.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sensors/temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;read_sensor&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;qos&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The bug spray!&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Silence is not success.&lt;/strong&gt; A green dashboard measures the things you thought to measure. My device was reporting perfect health while it was completely deaf to commands, because I'd never built anything that could notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a library leaves it to you, it will not warn you.&lt;/strong&gt; Reconnection, re-subscription, retry and queueing in &lt;code&gt;paho&lt;/code&gt; isn't broken for leaving these open. It's a protocol client, not a delivery guarantee. But every gap it leaves is a gap that fails in actual production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the log line you'd want at 2am.&lt;/strong&gt; Tests and structured logging exist in &lt;code&gt;robmqtt&lt;/code&gt; for one reason: I spent days chasing something that left no trace, and I refuse to do that to whoever picks this up next.&lt;/p&gt;

&lt;p&gt;The thing that cost me weeks is now one pip install away. That's all I wanted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;robmqtt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PyPI:&lt;/strong&gt; &lt;a href="https://pypi.org/project/robmqtt/" rel="noopener noreferrer"&gt;https://pypi.org/project/robmqtt/&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ranaweerasupun/resilient-edge-mqtt-client" rel="noopener noreferrer"&gt;https://github.com/ranaweerasupun/resilient-edge-mqtt-client&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Supun Sriyananda — R&amp;amp;D Engineer working on embedded and IoT systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>iot</category>
    </item>
    <item>
      <title>What a Hypervisor Is, and Where KVM Fits (Type-1 vs Type-2)</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Sat, 15 Aug 2026 11:36:51 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0</link>
      <guid>https://dev.to/ranaweerasupun/what-a-hypervisor-is-and-where-kvm-fits-type-1-vs-type-2-53j0</guid>
      <description>&lt;p&gt;This series builds the KVM/QEMU/libvirt stack starting from the silicon on ARM64. Commands are from a Raspberry Pi 5 (8 GB) running Raspberry Pi OS Bookworm, kernel 6.12 aarch64. Note that, Every command is aarch64-native, and the terminal output is real. The concepts, however, apply anywhere Linux runs. I chose ARM64 deliberately, because almost every KVM tutorial out there is written for x86.&lt;/p&gt;

&lt;p&gt;The first question to start with is: what is a hypervisor, and which kind is KVM?&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtualization works on a lie!
&lt;/h2&gt;

&lt;p&gt;A normal computer runs one operating system, and that OS believes it owns the hardware. It believes all the RAM, every CPU core, the disks, and the network interface belong to it. This belief is not a simplification for beginners. It is baked into how kernels are written. A kernel executes privileged instructions, sets up memory maps, talks directly to devices, and assumes nothing exists above it.&lt;/p&gt;

&lt;p&gt;A hypervisor is what lets several of those operating systems, each believing "I own everything", run on one machine. Each of them keeps believing the lie. The hypervisor's job is to keep that lie convincing enough and quietly share the real hardware underneath.&lt;/p&gt;

&lt;p&gt;Everything else I will be writing about in this series, like VM exits, VirtIO, virtqueues, and passthrough, is a technique for maintaining that lie either more convincingly or more cheaply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The types of hypervisors
&lt;/h2&gt;

&lt;p&gt;The textbook taxonomy divides hypervisors in two:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9t152h247yk1ysr45hp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9t152h247yk1ysr45hp.png" alt="types-of-hypervisors" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Type 1 hypervisors run directly on the hardware. There is no general-purpose OS underneath them, which means the hypervisor &lt;em&gt;is&lt;/em&gt; the thing the machine boots into. ESXi and Xen are the standard examples. What you get in return is performance and isolation. Because, the hypervisor is the sole layer separating a guest from the hardware, and there is nothing else in the way.&lt;/p&gt;

&lt;p&gt;Type 2 hypervisors run as applications on top of a normal operating system. We boot Linux, macOS, or Windows on our computers, then install a program like VirtualBox and launch it the same way we'd launch a browser. Here, what you get in return is convenience. Your usual desktop environment carries on exactly as before, and a virtual machine also ends up being just another application running alongside everything else.&lt;/p&gt;

&lt;p&gt;People usually summarise the difference as a choice between speed or convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where is KVM located. And is it type-1 or type-2 ?
&lt;/h2&gt;

&lt;p&gt;KVM is a kernel module. Once it is loaded, the Linux kernel itself &lt;em&gt;becomes&lt;/em&gt; the hypervisor. There is no separate hypervisor layer above or below it. The kernel you are already running gains the ability to execute guests directly on the hardware. I meant literally, not as an analogy. There is only one kernel here. It runs the ordinary applications, and it runs virtual machines too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae6bkvvr3pkkwazexm1b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fae6bkvvr3pkkwazexm1b.png" alt="where-is-kvm-located" width="800" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now let's try to place that on the Type-1/Type-2 chart.&lt;/p&gt;

&lt;p&gt;It has Type-1's defining property: the hypervisor communicates directly to the silicon, without an intermediary OS between it and the hardware. Guest code runs on real cores at native speed. There is no translation layer and no host kernel being asked to relay requests on the guest's behalf. The Type 1 label was invented to describe hypervisors that let guests run at full speed. KVM does exactly that, so by that reasoning, it belongs in the category.&lt;/p&gt;

&lt;p&gt;But it is &lt;em&gt;inside&lt;/em&gt; a general-purpose operating system that is simultaneously running SSH sessions, your browser, and a few dozen daemons. Your VMs appear in &lt;code&gt;ps&lt;/code&gt; output. They're scheduled by the ordinary Linux scheduler, they are killable with &lt;code&gt;kill&lt;/code&gt;, and constrained by cgroups like any other process. So, by the structural argument, that's unmistakably Type 2.&lt;/p&gt;

&lt;p&gt;This is why the argument never gets settled. People usually call KVM, "Type-1-ish", and that hedge is fair. Because, the two categories were named before anything like KVM existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical consequence of this categorization
&lt;/h2&gt;

&lt;p&gt;Because the hypervisor is part of a full Linux kernel, it inherits everything that kernel already knows how to do. It doesn't need its own scheduler, because Linux has one, and vCPUs become ordinary threads on it. It doesn't need its own memory manager, driver model, filesystem layer, or a network stack. Because linux has all of them. A dedicated Type-1 hypervisor has to build or port every one of those.&lt;/p&gt;

&lt;p&gt;This inheritance is going to shape this whole series. When we pin vCPU threads to physical cores, we'll be using the ordinary Linux scheduler affinity mechanism, because a vCPU is just a thread!. When guest disks turn out to be files in a directory, that's the host filesystem doing its normal job. Later when we bridge guest networking, we'll use the same Linux bridge that would connect any two interfaces.&lt;/p&gt;

&lt;p&gt;The more important point is the flip side: KVM is intentionally minimal. Its responsibilities begin and end with the CPU and memory. It does not know how to present itself as a disk, a network card, a display, or a USB port, and it was never designed to. The reason is that it does not need to. Inside the Linux kernel, mature subsystems already exist for storage, networking, and device management, and above the kernel, in userspace, QEMU takes care of whatever remains.&lt;/p&gt;

&lt;p&gt;That naturally leads to the question of how the work is actually split between these pieces. Article 1.3 in this series answers it. Before we get there, though, one thing needs to be cleared up, because saying that "the kernel becomes the hypervisor" ignores something important. A kernel cannot simply choose to run guest operating systems at native speed. It requires the processor to cooperate, and that cooperation comes from a hardware capability built into the silicon that stays inactive until software explicitly enables it.&lt;/p&gt;

&lt;p&gt;On ARM64 that feature is EL2, and it is where I will start in [the next article].&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A hypervisor lets multiple operating systems each believe they own the hardware, and shares the real hardware underneath.&lt;/li&gt;
&lt;li&gt;Type 1 runs directly on hardware (ESXi, Xen). Type 2 runs as an application on a host OS (VirtualBox).&lt;/li&gt;
&lt;li&gt;KVM is a kernel module that turns the running Linux kernel into a hypervisor. Guest code executes directly on real cores, but guests are also ordinary Linux processes.&lt;/li&gt;
&lt;li&gt;It's Type-1 by the performance argument and Type-2 by the structural one, hence "Type-1-ish". The taxonomy predates the design.&lt;/li&gt;
&lt;li&gt;Because the hypervisor is part of Linux, it reuses Linux's scheduler, memory manager, and drivers. Which is why KVM itself only handles CPU and memory.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>virtualization</category>
      <category>linux</category>
      <category>kvm</category>
      <category>arm</category>
    </item>
    <item>
      <title>MQTT vs HTTP for IoT: What I Learned Using Both in Production</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:00:26 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/mqtt-vs-http-for-iot-what-i-learned-using-both-in-production-1n5</link>
      <guid>https://dev.to/ranaweerasupun/mqtt-vs-http-for-iot-what-i-learned-using-both-in-production-1n5</guid>
      <description>&lt;p&gt;Every IoT project eventually reaches the same decision point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Should the device communicate with the cloud over MQTT or HTTP?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both protocols are mature, widely supported, and capable of moving device data reliably. The more useful question is not &lt;strong&gt;which protocol is better&lt;/strong&gt;, but &lt;strong&gt;which communication model fits the device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I have used MQTT for production edge clients running on deployed robot units and warehouse sensors. I have also used HTTP for a building-management monitoring backend and a smart-entry system built with Flask.&lt;/p&gt;

&lt;p&gt;Both worked. They simply created very different systems.&lt;/p&gt;

&lt;p&gt;This article explains the trade-offs I saw in practice: bandwidth overhead, connection behavior, command latency, power consumption, infrastructure, fan-out, and operational complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural difference that drives everything else
&lt;/h2&gt;

&lt;p&gt;HTTP is built around a request-response model. A client sends a request, and a server returns a response.&lt;/p&gt;

&lt;p&gt;MQTT is built around persistent connections and publish-subscribe messaging. A device connects to a broker, publishes messages to topics, and subscribes to topics it wants to receive.&lt;/p&gt;

&lt;p&gt;That difference shapes almost every other trade-off.&lt;/p&gt;

&lt;p&gt;With HTTP, a device usually addresses a specific API endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/readings
GET /devices/robot-01/commands
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With MQTT, the device communicates through topics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warehouse/sensor-01/temperature
robots/robot-01/commands
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher does not need to know which services consume a message. The broker handles routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bandwidth and message overhead
&lt;/h2&gt;

&lt;p&gt;The difference becomes easy to see when you compare a small sensor reading.&lt;/p&gt;

&lt;p&gt;An HTTP request might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/api/sensors/temperature&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer &amp;lt;token&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;Content-Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;52&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"device_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sensor_01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;23.5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is small, but the request also carries headers. Authentication tokens can make those headers much larger than the sensor reading itself.&lt;/p&gt;

&lt;p&gt;An MQTT &lt;code&gt;PUBLISH&lt;/code&gt; packet carries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fixed header
Topic name
Packet identifier, when required by QoS
Properties, in MQTT 5
Payload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a short topic and a small payload, the MQTT packet can be substantially smaller than an equivalent HTTP request. The MQTT fixed header starts at two bytes, although the complete packet is larger once the topic, payload, QoS fields, and any MQTT 5 properties are included.&lt;/p&gt;

&lt;p&gt;The comparison also depends on connection reuse.&lt;/p&gt;

&lt;p&gt;HTTP/1.1 supports persistent connections by default, so a well-configured client does &lt;strong&gt;not&lt;/strong&gt; need to open a new TCP and TLS connection for every request. HTTP/2 can also multiplex many requests over one connection. Poorly configured clients, short-lived processes, network timeouts, and reconnects can still cause repeated handshakes, but “one request equals one new connection” is not an accurate general rule.&lt;/p&gt;

&lt;p&gt;MQTT still has an advantage for frequent, small messages because its application-layer framing is compact and the connection is normally kept open for ongoing messaging.&lt;/p&gt;

&lt;p&gt;For one device publishing every few minutes, the absolute difference may not matter. Across hundreds of devices publishing several times per second, it can become significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving commands: polling versus subscriptions
&lt;/h2&gt;

&lt;p&gt;The biggest practical difference I encountered was server-to-device communication.&lt;/p&gt;

&lt;p&gt;A basic HTTP API is client-initiated. The device sends a request before the server can return anything. A simple implementation therefore polls for commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_for_commands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/devices/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/commands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="nf"&gt;handle_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a five-second interval, that is 17,280 requests per device per day, including requests that return no commands. A newly created command may also wait almost five seconds before the next poll.&lt;/p&gt;

&lt;p&gt;HTTP-based systems can avoid simple polling with techniques such as long polling, Server-Sent Events, or WebSockets. Those are valid designs, but they add a different connection-management model to the application.&lt;/p&gt;

&lt;p&gt;MQTT supports this pattern directly. The device subscribes once, and the broker forwards matching messages over the existing connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;paho.mqtt.client&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mqtt&lt;/span&gt;

&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robot-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BROKER_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mqtt.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;devices/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/commands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qos&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;handle_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mqtt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mqtt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallbackAPIVersion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VERSION2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DEVICE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_connect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;on_connect&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;on_message&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BROKER_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1883&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepalive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loop_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the telepresence robot, this mattered. Camera and movement-related commands needed low and predictable latency. A five-second polling loop was not acceptable, while an MQTT subscription matched the communication pattern naturally.&lt;/p&gt;

&lt;p&gt;For truly hard real-time control, however, neither MQTT nor a general-purpose cloud connection should be treated as a deterministic control bus. Network delay, broker load, retransmission, and connectivity loss still need to be considered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability and QoS
&lt;/h2&gt;

&lt;p&gt;MQTT offers three protocol-level Quality of Service levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QoS 0:&lt;/strong&gt; delivered at most once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS 1:&lt;/strong&gt; delivered at least once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS 2:&lt;/strong&gt; delivered exactly once between MQTT protocol peers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;QoS 1 is useful when losing a message is worse than processing a duplicate. The application must therefore be able to handle duplicate delivery safely.&lt;/p&gt;

&lt;p&gt;QoS 2 reduces duplicate delivery at the MQTT protocol layer, but it adds more packet exchanges and does not automatically make the entire application workflow exactly once. A message can still be processed twice if application state, database writes, retries, or downstream integrations are not idempotent.&lt;/p&gt;

&lt;p&gt;HTTP can also be reliable, but the retry and deduplication behavior is usually designed at the application layer. For example, a device can retry a failed &lt;code&gt;POST&lt;/code&gt; with an idempotency key, while the API stores the key to avoid inserting the same reading twice.&lt;/p&gt;

&lt;p&gt;The important point is that neither protocol removes the need to design failure handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Power consumption is workload-dependent
&lt;/h2&gt;

&lt;p&gt;It is tempting to say that MQTT always uses less power because its packets are smaller. The real answer is more nuanced.&lt;/p&gt;

&lt;p&gt;Radio state often matters more than payload size. Establishing a cellular or Wi-Fi connection, negotiating TCP and TLS, and waiting for network timers can consume more energy than transmitting a short reading.&lt;/p&gt;

&lt;p&gt;A continuously connected MQTT client can avoid repeated connection setup and receive commands immediately. That can be efficient for devices that communicate frequently or must remain reachable.&lt;/p&gt;

&lt;p&gt;However, maintaining a live MQTT connection requires periodic keepalive traffic and may prevent some devices or modems from entering their deepest sleep states. MQTT &lt;code&gt;PINGREQ&lt;/code&gt; and &lt;code&gt;PINGRESP&lt;/code&gt; packets maintain the connection; they do not allow a device to turn its radio fully off while keeping the same TCP connection alive.&lt;/p&gt;

&lt;p&gt;For a battery-powered sensor that wakes once an hour, uploads one reading, and never receives commands, a short HTTPS request may be simpler and just as efficient. The device must reconnect either way after deep sleep.&lt;/p&gt;

&lt;p&gt;MQTT persistent sessions are still valuable for intermittently connected devices. A broker can preserve subscriptions and queue eligible QoS messages while the client is offline, subject to the broker configuration, protocol version, session settings, and retention limits. When the device reconnects and resumes the session, it can receive the stored messages.&lt;/p&gt;

&lt;p&gt;So the useful rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Frequent bidirectional communication often favors MQTT.&lt;/li&gt;
&lt;li&gt;Rare uplink-only communication may favor HTTP.&lt;/li&gt;
&lt;li&gt;Measure on the actual radio, modem, network, and sleep schedule before making a battery-life claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Infrastructure and operational complexity
&lt;/h2&gt;

&lt;p&gt;HTTP is easy to start with because almost every platform already supports it.&lt;/p&gt;

&lt;p&gt;A small ingestion API can be only a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/readings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive_reading&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;readings.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO readings (timestamp, device_id, value)
            VALUES (?, ?, ?)
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tooling is familiar: &lt;code&gt;curl&lt;/code&gt;, Postman, browser developer tools, reverse proxies, API gateways, serverless functions, status codes, and standard observability platforms.&lt;/p&gt;

&lt;p&gt;MQTT requires a broker. Mosquitto is a popular lightweight open-source option, while managed services such as AWS IoT Core and HiveMQ Cloud reduce the operational burden.&lt;/p&gt;

&lt;p&gt;Running a broker means thinking about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device authentication&lt;/li&gt;
&lt;li&gt;TLS certificates&lt;/li&gt;
&lt;li&gt;Topic-level authorization&lt;/li&gt;
&lt;li&gt;Session persistence&lt;/li&gt;
&lt;li&gt;Message expiry and retained messages&lt;/li&gt;
&lt;li&gt;Broker storage&lt;/li&gt;
&lt;li&gt;Monitoring and capacity&lt;/li&gt;
&lt;li&gt;Reconnection storms&lt;/li&gt;
&lt;li&gt;High availability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single broker instance can become a single point of failure, but MQTT itself does not require a single-instance deployment. Production systems can use clustered brokers, load-balanced endpoints, replicated state, and managed broker services.&lt;/p&gt;

&lt;p&gt;HTTP is not automatically immune to failure either. A single Flask process is also a single point of failure. HTTP infrastructure is often easier to scale horizontally because stateless APIs and load balancers are so widely supported, but both architectures require deliberate redundancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out is where MQTT feels fundamentally different
&lt;/h2&gt;

&lt;p&gt;MQTT is especially useful when one message has several consumers.&lt;/p&gt;

&lt;p&gt;A sensor can publish once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sensors/warehouse/temp01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several services can subscribe independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dashboard
Alerting service
Time-series database
Analytics pipeline
Anomaly detector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sensor does not need to know that these consumers exist. A new consumer can be added without changing device firmware.&lt;/p&gt;

&lt;p&gt;With HTTP, the device can still send one request to an ingestion service, but that service must then distribute the event. This is commonly done with a queue, event bus, stream, or webhook system.&lt;/p&gt;

&lt;p&gt;That architecture can be excellent. It simply means that HTTP handles device ingestion while another system provides asynchronous fan-out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging and developer experience
&lt;/h2&gt;

&lt;p&gt;HTTP is usually easier to inspect manually.&lt;/p&gt;

&lt;p&gt;You can reproduce a request with &lt;code&gt;curl&lt;/code&gt;, view status codes, inspect headers, and test an endpoint without maintaining a long-lived client session.&lt;/p&gt;

&lt;p&gt;MQTT debugging is still manageable, but it requires different tools and concepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosquitto_sub &lt;span class="nt"&gt;-h&lt;/span&gt; broker.example.com &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'sensors/#'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mosquitto_pub &lt;span class="nt"&gt;-h&lt;/span&gt; broker.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s1"&gt;'devices/robot-01/commands'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s1"&gt;'{"action":"stop"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-q&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need to understand wildcard subscriptions, retained messages, session state, QoS acknowledgements, duplicate delivery, and topic permissions.&lt;/p&gt;

&lt;p&gt;In my experience, HTTP produces a faster first prototype. MQTT often produces a cleaner final design once the system becomes continuously connected, bidirectional, or event-driven.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision framework
&lt;/h2&gt;

&lt;p&gt;Choose &lt;strong&gt;HTTP&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The device integrates with an existing REST API.&lt;/li&gt;
&lt;li&gt;Communication is infrequent and mostly device-to-cloud.&lt;/li&gt;
&lt;li&gt;The device uploads large payloads, images, logs, or firmware files.&lt;/li&gt;
&lt;li&gt;Simple debugging and minimal infrastructure matter most.&lt;/li&gt;
&lt;li&gt;Server-to-device messaging is unnecessary or can tolerate polling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose &lt;strong&gt;MQTT&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Devices must receive commands or configuration changes quickly.&lt;/li&gt;
&lt;li&gt;Messages are small and frequent.&lt;/li&gt;
&lt;li&gt;Network links are unreliable or intermittent.&lt;/li&gt;
&lt;li&gt;Multiple services need the same device events.&lt;/li&gt;
&lt;li&gt;Session state, topic subscriptions, and QoS are useful.&lt;/li&gt;
&lt;li&gt;Bandwidth efficiency matters across a large fleet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;strong&gt;both&lt;/strong&gt; when the workloads differ.&lt;/p&gt;

&lt;p&gt;That is the design I now prefer for many IoT systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MQTT for telemetry, commands, device status, and real-time events&lt;/li&gt;
&lt;li&gt;HTTP for firmware downloads, image uploads, administration, and third-party integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broker handles operational messaging. The API handles resource-oriented workflows and large transfers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaway
&lt;/h2&gt;

&lt;p&gt;The decision is not really “MQTT or HTTP?”&lt;/p&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this device behave more like an API client, or more like a participant in a live event system?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For an occasional one-way upload, HTTP is often the simplest answer.&lt;/p&gt;

&lt;p&gt;For a connected fleet that publishes continuously, receives commands, survives intermittent networks, and feeds several downstream services, MQTT usually fits better.&lt;/p&gt;

&lt;p&gt;In production, the strongest architecture is often not choosing one protocol everywhere. It is giving each protocol the job it was designed to do.&lt;/p&gt;




&lt;p&gt;Have you used MQTT and HTTP in the same IoT system? I would be interested to hear where you drew the boundary between them.&lt;/p&gt;

</description>
      <category>iot</category>
      <category>mqtt</category>
      <category>http</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Python vs C++ for Embedded Systems: When to Use Each</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:03:04 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/python-vs-c-for-embedded-systems-when-to-use-each-4nno</link>
      <guid>https://dev.to/ranaweerasupun/python-vs-c-for-embedded-systems-when-to-use-each-4nno</guid>
      <description>&lt;p&gt;When you first step into the world of embedded systems, one of the earliest and most consequential decisions you will face is choosing a programming language. Two names come up more than any others: Python and C++. Both are powerful, both have passionate communities, and both are genuinely useful — but for very different reasons and in very different contexts. This article is not about declaring a winner. It is about understanding &lt;em&gt;why&lt;/em&gt; each language exists in this space, what trade-offs you are actually making, and how to make a confident, informed decision for your next project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding the Fundamental Difference
&lt;/h2&gt;

&lt;p&gt;Before comparing features, it helps to understand why these two languages feel so different at a deeper level.&lt;/p&gt;

&lt;p&gt;C++ is a &lt;strong&gt;compiled, statically-typed, systems-level language&lt;/strong&gt;. When you write C++, you are writing code that gets translated directly into machine instructions. You manage memory manually. You control exactly when objects are created and destroyed. The hardware does precisely what you tell it to, nothing more and nothing less. This directness is both its superpower and its source of complexity.&lt;/p&gt;

&lt;p&gt;Python, by contrast, is an &lt;strong&gt;interpreted, dynamically-typed, high-level language&lt;/strong&gt;. A Python runtime sits between your code and the hardware, managing memory automatically through garbage collection, resolving types at runtime, and handling a lot of bookkeeping so you don't have to. This makes Python wonderfully expressive and fast to write, but it introduces overhead that matters enormously on constrained hardware.&lt;/p&gt;

&lt;p&gt;The mental model to hold onto is this: &lt;strong&gt;C++ gives you control, Python gives you speed of development&lt;/strong&gt;. Both are valuable. The question is which one your project needs more.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where C++ Shines in Embedded Systems
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Bare-Metal and Resource-Constrained Environments
&lt;/h3&gt;

&lt;p&gt;If you are programming a microcontroller like an STM32, an AVR ATmega, or an ESP32 running its native SDK, C++ is almost always your primary language. These devices often have kilobytes — not megabytes — of RAM, and they have no operating system to fall back on. Python's runtime alone would consume more memory than the entire chip provides.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// C++ on a bare-metal microcontroller (e.g., STM32 HAL)&lt;/span&gt;
&lt;span class="c1"&gt;// Direct register manipulation gives you precise, deterministic control&lt;/span&gt;
&lt;span class="c1"&gt;// over hardware peripherals with zero overhead&lt;/span&gt;

&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;"stm32f4xx_hal.h"&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="c1"&gt;// A simple GPIO toggle to blink an LED — every cycle counts here&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;blink_led&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GPIO_TypeDef&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;delay_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;HAL_GPIO_TogglePin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Direct hardware write&lt;/span&gt;
        &lt;span class="n"&gt;HAL_Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay_ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// Blocking delay — acceptable on bare metal&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight here is &lt;strong&gt;determinism&lt;/strong&gt;. In safety-critical or real-time systems — think motor controllers, medical devices, or industrial sensors — you need to guarantee that a particular piece of code executes within a precise time window. Python's garbage collector can pause your program at unpredictable moments. C++ has no such hidden pauses.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Real-Time Operating Systems (RTOS)
&lt;/h3&gt;

&lt;p&gt;When you step up to an RTOS like FreeRTOS, Zephyr, or ThreadX, C++ remains the dominant choice. RTOS kernels are themselves written in C/C++, and the APIs they expose are designed for direct use from those languages. You get preemptive multitasking, precise interrupt handling, and microsecond-level timing — all with the kind of control that C++ makes natural.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FreeRTOS task written in C++ — structured, type-safe, and efficient&lt;/span&gt;
&lt;span class="c1"&gt;// Each task runs as an independent thread with its own stack allocation&lt;/span&gt;

&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;"FreeRTOS.h"&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;"task.h"&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SensorTask&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="nl"&gt;public:&lt;/span&gt;
    &lt;span class="c1"&gt;// Static wrapper needed because FreeRTOS expects a plain C function pointer&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;taskEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Cast back to our class instance — a common C++/RTOS pattern&lt;/span&gt;
        &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SensorTask&lt;/span&gt;&lt;span class="o"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Stack size (512 words) and priority are explicit — you own these decisions&lt;/span&gt;
        &lt;span class="n"&gt;xTaskCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taskEntry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Sensor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tskIDLE_PRIORITY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;handle_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;TaskHandle_t&lt;/span&gt; &lt;span class="n"&gt;handle_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;readSensor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;vTaskDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdMS_TO_TICKS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// Yield for exactly 100ms&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;readSensor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Hardware-specific sensor reading logic lives here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Performance-Critical Signal Processing
&lt;/h3&gt;

&lt;p&gt;Digital signal processing (DSP), motor control algorithms, PID loops, and image processing pipelines all benefit enormously from C++. The language lets you write tightly optimized loops, use SIMD intrinsics, and avoid any dynamic allocation in hot paths. When you need to process a sensor stream at 10 kHz with a 50-microsecond deadline, Python is not a practical option.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Production Firmware
&lt;/h3&gt;

&lt;p&gt;If you are shipping a product — a consumer device, an industrial controller, a wearable — your firmware will almost certainly be C or C++. It compiles to a fixed binary, has predictable resource usage, can be certified against safety standards, and does not require a runtime environment on the target device.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Python Shines in Embedded Systems
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Rapid Prototyping on Linux-Based Boards
&lt;/h3&gt;

&lt;p&gt;Single-board computers like the Raspberry Pi, BeagleBone, or NVIDIA Jetson run full Linux, which means they can run a complete Python interpreter. On these platforms, Python becomes extraordinarily powerful for prototyping. You can wire up a sensor, write twenty lines of Python, and have a working proof-of-concept in under an hour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python on Raspberry Pi using the RPi.GPIO library
# This simplicity is Python's greatest asset for hardware prototyping
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RPi.GPIO&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;GPIO&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;LED_PIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;  &lt;span class="c1"&gt;# BCM numbering
&lt;/span&gt;
&lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setmode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BCM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LED_PIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# A PWM-based fade effect — would take far more boilerplate in C++
&lt;/span&gt;&lt;span class="n"&gt;pwm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PWM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LED_PIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 100 Hz frequency
&lt;/span&gt;&lt;span class="n"&gt;pwm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Gradually increase and decrease brightness
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;duty_cycle&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;pwm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ChangeDutyCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duty_cycle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;duty_cycle&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;pwm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ChangeDutyCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duty_cycle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pwm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;GPIO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Always clean up GPIO state on exit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. MicroPython and CircuitPython on Microcontrollers
&lt;/h3&gt;

&lt;p&gt;This is where the story gets more nuanced. MicroPython and Adafruit's CircuitPython are lean Python implementations designed specifically to run on microcontrollers like the RP2040, ESP32, and STM32. They bring Python's expressiveness to hardware with just a few hundred kilobytes of flash.&lt;/p&gt;

&lt;p&gt;These environments are excellent for education, hobbyist projects, and rapid iteration. The trade-off is that you are running an interpreter with managed memory, so real-time guarantees are off the table and performance is a fraction of native C++.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# MicroPython on an RP2040 (Raspberry Pi Pico)
# The simplicity here is remarkable — I2C in just a few lines
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;machine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;I2C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Pin&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="c1"&gt;# Initialize I2C bus on pins 4 (SDA) and 5 (SCL)
&lt;/span&gt;&lt;span class="n"&gt;i2c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;I2C&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sda&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Pin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;scl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Pin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;freq&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Scan for connected devices — invaluable during prototyping
&lt;/span&gt;&lt;span class="n"&gt;devices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i2c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Found I2C devices at addresses: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Read 6 bytes from a hypothetical sensor at address 0x68
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="mh"&gt;0x68&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i2c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readfrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Parse raw bytes — still manageable in Python
&lt;/span&gt;    &lt;span class="n"&gt;accel_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;big&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accelerometer X: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;accel_x&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Data Pipelines, ML Inference, and High-Level Logic
&lt;/h3&gt;

&lt;p&gt;On Linux-based edge devices, Python's ecosystem is unmatched. Libraries like NumPy, OpenCV, TensorFlow Lite, and PySerial let you build sophisticated data pipelines, run machine learning inference, and communicate with sensors — all without writing a single line of C++. This is where Python earns its place even in serious embedded projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Testing, Tooling, and Automation
&lt;/h3&gt;

&lt;p&gt;Even on projects where the final firmware is C++, Python plays a huge role in the surrounding ecosystem. Test frameworks, hardware-in-the-loop test scripts, build automation, log parsers, and configuration generators are all commonly written in Python. It is the glue language of the embedded world.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Direct Comparison Across Key Dimensions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Performance
&lt;/h3&gt;

&lt;p&gt;C++ wins decisively here. A well-written C++ program will typically run 10x to 100x faster than equivalent Python, and in tight real-time loops the gap is even larger. On microcontrollers without a Python runtime, C++ is often the &lt;em&gt;only&lt;/em&gt; option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Development Speed
&lt;/h3&gt;

&lt;p&gt;Python wins just as decisively. The combination of dynamic typing, interactive REPL, extensive libraries, and concise syntax means you can explore ideas, test hypotheses, and build prototypes dramatically faster in Python than in C++.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Usage
&lt;/h3&gt;

&lt;p&gt;C++ gives you granular control. You can preallocate fixed-size buffers, use stack memory exclusively in critical sections, and tune every byte of usage. Python's runtime itself consumes significant memory before your application even starts — typically several megabytes for CPython, several hundred kilobytes for MicroPython.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ecosystem and Libraries
&lt;/h3&gt;

&lt;p&gt;For general-purpose computing tasks (networking, data science, ML, web APIs), Python's ecosystem is simply enormous. For low-level hardware interaction, peripheral drivers, and embedded frameworks, the C/C++ ecosystem has decades of depth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debugging and Observability
&lt;/h3&gt;

&lt;p&gt;Python is easier to debug interactively. You can print, inspect, and modify state at runtime with minimal friction. C++ debugging on embedded targets requires more tooling — JTAG/SWD debuggers, GDB, logic analyzers — but that tooling is very mature and gives you deep visibility into the hardware.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safety and Reliability
&lt;/h3&gt;

&lt;p&gt;C++ makes it possible (with discipline) to write code with no dynamic allocation, bounded execution time, and no hidden runtime behavior. This is essential for safety-critical systems. Python's garbage collector and dynamic nature make such guarantees essentially impossible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hybrid Approach: Using Both
&lt;/h2&gt;

&lt;p&gt;In practice, many serious embedded projects use both languages in complementary roles. A common architecture looks like this:&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;C++ layer&lt;/strong&gt; handles all time-critical operations: sensor interrupt handlers, motor control loops, communication protocol drivers, and safety watchdogs. This layer is compiled to a binary that runs deterministically on the target hardware.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Python layer&lt;/strong&gt; sits above it, handling high-level orchestration: reading data from the C++ layer over a serial or USB interface, applying machine learning models, sending data to cloud services, and providing a configuration interface. On a Raspberry Pi, this layer might coordinate with a microcontroller over UART while simultaneously pushing data to an MQTT broker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python orchestration layer on a Raspberry Pi
# Talking to a C++ firmware over serial — a very common real-world pattern
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;serial&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FirmwareBridge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    High-level Python wrapper around low-level C++ firmware.
    The firmware handles real-time control; we handle logic and connectivity.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;115200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Serial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baud&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_sensor_packet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Read a binary packet from firmware.
        The C++ side sends fixed-width structs for efficiency.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mh"&gt;0xAA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Sync byte
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="c1"&gt;# Unpack a 3-float struct: temperature, humidity, pressure
&lt;/span&gt;        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;humidity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pressure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;fff&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;humidity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;humidity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pressure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pressure&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Send a control command down to the C++ firmware.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0xBB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This hybrid model lets you play to each language's strengths: C++ for the parts where performance and determinism are non-negotiable, Python for the parts where developer velocity and ecosystem richness matter most.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Framework: How to Choose
&lt;/h2&gt;

&lt;p&gt;Rather than a rigid rule, think of it as a set of questions to work through for any given project or module.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Does this code have hard real-time requirements?&lt;/strong&gt; If yes, use C++. Real-time means a missed deadline is a failure, not just a slowdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Does this run on a microcontroller without an OS?&lt;/strong&gt; If yes, use C++. Python runtimes need significantly more resources than bare-metal MCUs typically provide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Am I building a proof-of-concept or exploring hardware behavior?&lt;/strong&gt; If yes, start with Python (on a suitable platform). Validate your idea fast before optimizing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Does this code interact with complex libraries — ML, computer vision, networking?&lt;/strong&gt; If yes, Python is likely more practical. The C++ equivalents exist but are far harder to integrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Will this code ship in a product with safety or reliability requirements?&lt;/strong&gt; If yes, lean heavily toward C++, potentially with a formal coding standard like MISRA C++.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask: Is this part of a larger system's tooling, testing, or configuration?&lt;/strong&gt; If yes, Python is almost certainly the right choice regardless of what the firmware itself uses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Python vs C++ question in embedded systems is not a rivalry — it is a spectrum. C++ is the language of determinism, performance, and direct hardware control. Python is the language of expressiveness, rapid iteration, and ecosystem richness. Understanding &lt;em&gt;why&lt;/em&gt; each language works the way it does is far more valuable than memorizing a comparison table.&lt;/p&gt;

&lt;p&gt;The most effective embedded engineers are fluent in both. They reach for C++ when they need to talk directly to hardware with precision and speed, and they reach for Python when they need to move fast, analyze data, or connect their hardware to the broader software world. Knowing which tool to pick up, and why, is the skill worth developing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>cpp</category>
      <category>embedded</category>
      <category>iot</category>
    </item>
    <item>
      <title>Implementing Exponential Backoff: Preventing Thundering Herd Problems</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Wed, 08 Jul 2026 08:47:10 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/implementing-exponential-backoff-preventing-thundering-herd-problems-270d</link>
      <guid>https://dev.to/ranaweerasupun/implementing-exponential-backoff-preventing-thundering-herd-problems-270d</guid>
      <description>&lt;p&gt;When a broker goes down and a thousand edge devices all try to reconnect every second, the moment the broker comes back online it gets hit with a thousand simultaneous connection requests and dies again. This is the thundering herd problem, and it is entirely self-inflicted. The fix is exponential backoff with jitter — a reconnection strategy every networked embedded application should implement from day one.&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;See project on GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ranaweerasupun/resilient-edge-mqtt-client" rel="noopener noreferrer"&gt;Resilient Edge MQTT Client&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong Way
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrb0c6b40ulwva20cm75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frrb0c6b40ulwva20cm75.png" alt="thundering herd vs. backoff" width="800" height="573"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Every device retries every second. 1000 devices = 1000 req/s on reconnect.
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Fixed delay — all devices retry in lockstep
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixed-delay retry is the problem. Every device that disconnected at the same time will retry at exactly the same time, forever, until one of them happens to succeed. At scale, this makes outages self-perpetuating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Right Way
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogp4oke05vo51hx2xy68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogp4oke05vo51hx2xy68.png" alt="exponential delay growth" width="800" height="417"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;connect_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;min_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Reconnect with exponential backoff and jitter.

    Delay sequence (approximate): 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s...
    Jitter adds ±10% randomness so devices that disconnected together
    do not retry together.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min_delay&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Jitter is proportional to the current delay, not fixed.
&lt;/span&gt;            &lt;span class="c1"&gt;# At delay=1 it adds up to 0.1s. At delay=60 it adds up to 6s.
&lt;/span&gt;            &lt;span class="c1"&gt;# This keeps devices spread out even after long outages.
&lt;/span&gt;            &lt;span class="n"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# Success — caller resets delay to min_delay
&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connection failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Retrying in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three decisions baked into this function are worth understanding individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doubling the delay&lt;/strong&gt; (the exponential part) means the load on the broker drops by half with each retry cycle. After a few rounds, devices are spread so far apart in time that the broker recovers comfortably before the next wave arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capping at &lt;code&gt;max_delay&lt;/code&gt;&lt;/strong&gt; prevents devices from backing off indefinitely. A cap of 60–300 seconds is usually right for embedded systems — long enough to give the broker real recovery time, short enough that you do not lose hours of data during a brief outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proportional jitter&lt;/strong&gt; is the part most implementations get wrong. Adding a small fixed jitter like &lt;code&gt;random.uniform(0, 0.5)&lt;/code&gt; works when delay is 1 second but is meaningless when delay is 60 seconds — all devices are still retrying within the same half-second window. Making jitter proportional to the current delay (10% here) keeps devices spread out at every stage of backoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Ways to Add Jitter
&lt;/h2&gt;

&lt;p&gt;The 10% proportional jitter above is deliberately conservative — it keeps retries close to the intended backoff curve while still desynchronizing devices. But it's not the only approach, and for larger fleets more aggressive strategies spread load better. These are the three established patterns, all built on the same base calculation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# uncapped exponential value
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Full Jitter&lt;/strong&gt; picks the delay uniformly between zero and the full exponential value. This maximizes spread but sacrifices any guaranteed minimum wait — some devices will retry almost immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Equal Jitter&lt;/strong&gt; keeps half the delay fixed and randomizes the other half. This guarantees a minimum wait while still breaking synchronization — a good middle ground when you don't want devices hammering back instantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decorrelated Jitter&lt;/strong&gt; bases each delay on the &lt;em&gt;previous&lt;/em&gt; sleep time rather than the attempt count, letting the backoff wander upward more smoothly. AWS's analysis found this among the most effective at minimizing total work against a recovering service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_sleep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a small edge fleet, proportional or equal jitter is plenty. For thousands of clients hitting a shared API, full or decorrelated jitter spreads the recovery load more evenly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reset the Delay on Success
&lt;/h2&gt;

&lt;p&gt;This is the step that gets forgotten. After a successful connection, the delay must reset to &lt;strong&gt;min_delay&lt;/strong&gt;. If it stays at whatever value it reached during the outage, the next disconnection — even a brief one — will start with a 60-second wait instead of a 1-second wait.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ResilientMQTTClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;broker_port&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;broker_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;broker_host&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;broker_port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;broker_port&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_delay&lt;/span&gt;  &lt;span class="c1"&gt;# Reset this on every successful connect
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userdata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_delay&lt;/span&gt;  &lt;span class="c1"&gt;# Always reset here
&lt;/span&gt;            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connected — backoff delay reset to minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Paho MQTT, the right place to reset is inside the &lt;strong&gt;on_connect&lt;/strong&gt; callback, which fires on the background network thread whenever a connection is established. Resetting in the reconnection loop itself is too early — the connection might still fail after the reset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a Restart Rate Limit at the System Level
&lt;/h2&gt;

&lt;p&gt;Backoff handles network-level reconnection. But if your application itself is crashing and restarting — not just reconnecting — you need a second line of defence. systemd's &lt;em&gt;StartLimitBurst&lt;/em&gt; and &lt;em&gt;StartLimitIntervalSec&lt;/em&gt; directives cap how many times a service can restart within a given window before systemd stops trying and marks it failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5                  # Base delay before first restart&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;120     # Window for counting restarts&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5             # Give up after 5 restarts in 120 seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents a crashing service from hammering a broker with rapid reconnections that bypass your application-level backoff entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Retry Blindly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs603vbzqcobgptzv6blq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs603vbzqcobgptzv6blq.png" alt="retry decision flow" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Backoff controls &lt;em&gt;when&lt;/em&gt; you retry. It says nothing about &lt;em&gt;whether&lt;/em&gt; you should — and retrying the wrong things causes worse problems than the outage itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every error is retryable.&lt;/strong&gt; Retry only on transient failures: connection timeouts and 5xx responses like 500 and 503, which mean the service is temporarily unwell and may recover. Do not retry client errors like 401 or 404 — a request that's unauthorized or points at something that doesn't exist will fail identically no matter how many times you send it. Retrying it just wastes cycles and pollutes your logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-idempotent operations need protection.&lt;/strong&gt; If a retry can duplicate a side effect — charging a card, creating a record, sending a message — then a retry that succeeds &lt;em&gt;after&lt;/em&gt; the original silently went through will corrupt your data. The standard fix is an idempotency key: the client attaches a unique token to the request, and the server uses it to recognize and deduplicate a retry of an operation it already completed. Reconnecting to a broker is naturally idempotent; posting a payment is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always set an attempt limit.&lt;/strong&gt; The &lt;code&gt;max_delay&lt;/code&gt; cap stops any single wait from growing unbounded, but a client can still retry a capped delay forever. For request/response operations, give up after a fixed number of attempts (typically 5–7) and surface the error to the caller rather than retrying into the void. Persistent connections like MQTT are the exception — there, retrying indefinitely against &lt;code&gt;max_delay&lt;/code&gt; is usually what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;p&gt;Enable exponential backoff on every service that connects to an external broker, API, or database — not just MQTT. Start with &lt;strong&gt;min_delay=1&lt;/strong&gt;, &lt;strong&gt;max_delay=60&lt;/strong&gt; for most edge applications. Use proportional jitter (10% of current delay) for small fleets, or full/decorrelated jitter when thousands of clients share a service — never fixed jitter. Always reset the delay counter inside the successful-connection callback. Retry only transient errors (timeouts, 5xx), never client errors (4xx). Protect non-idempotent operations with an idempotency key, and cap request/response retries at 5–7 attempts. Pair application-level backoff with systemd restart rate limiting for complete coverage.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, drop a ❤️ or a comment — I'd love to hear how you handle reconnection in your own systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>python</category>
      <category>distributedsystems</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Why SQLite is Perfect for Edge Device Data Logging</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Thu, 02 Jul 2026 08:57:42 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/why-sqlite-is-perfect-for-edge-device-data-logging-1epk</link>
      <guid>https://dev.to/ranaweerasupun/why-sqlite-is-perfect-for-edge-device-data-logging-1epk</guid>
      <description>&lt;p&gt;Most embedded Linux projects do not need a database server. They need something that stores data reliably when the network is down, survives a power cut, does not eat RAM, and requires zero administration. SQLite does all of that. It is a single &lt;code&gt;.db&lt;/code&gt; file on disk, it has been in production use for over twenty years, and it is already installed on virtually every Linux system you will ever deploy to. If you are currently writing sensor data to a flat file or a custom binary format, this article will show you why SQLite is almost always the better choice — and exactly how to use it correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;See the project on GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ranaweerasupun/resilient-edge-mqtt-client" rel="noopener noreferrer"&gt;Resilient Edge MQTT client&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable WAL Mode Before Anything Else
&lt;/h2&gt;

&lt;p&gt;The single most impactful thing you can do is switch from the default journal mode to Write-Ahead Logging. In the default mode, SQLite locks the entire database file during a write, which means a reader has to wait. WAL mode allows reads and writes to happen simultaneously, which matters enormously when you have one thread logging sensor data and another thread reading it for transmission. It also makes crash recovery safer — incomplete writes never corrupt the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;

&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensor_data.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Do this immediately after opening — before any other operations
&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA journal_mode=WAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA synchronous=NORMAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Safe with WAL, much faster than FULL
&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;synchronous=NORMAL&lt;/code&gt; is safe to use in combination with WAL mode. The default &lt;code&gt;FULL&lt;/code&gt; mode flushes to disk on every transaction, which is slow on SD cards and eMMC storage. &lt;code&gt;NORMAL&lt;/code&gt; flushes at critical checkpoints instead, giving you durability against crashes while being significantly faster on the kinds of storage you find in embedded devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema Design: Keep It Flat, Add the Right Indexes
&lt;/h2&gt;

&lt;p&gt;Resist the urge to normalise aggressively. On an edge device you are almost always inserting one kind of data repeatedly and querying it by time range. A flat table with a proper index on the timestamp column handles both operations efficiently and keeps queries simple.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    CREATE TABLE IF NOT EXISTS sensor_readings (
        id        INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp REAL    NOT NULL,  -- Unix epoch, fractional seconds
        topic     TEXT    NOT NULL,  -- e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors/warehouse/temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
        payload   TEXT    NOT NULL,  -- JSON string
        synced    INTEGER NOT NULL DEFAULT 0  -- 0 = pending upload, 1 = done
    )
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# This index makes time-range queries fast and powers the sync queue
&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    CREATE INDEX IF NOT EXISTS idx_timestamp_synced
    ON sensor_readings(timestamp, synced)
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;synced&lt;/code&gt; column is a pattern worth adopting from the start. Edge devices often need to buffer data locally and then upload it in batches when connectivity returns. Tracking sync state in the same table keeps everything in one place and makes the query for "give me everything not yet sent" trivially simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sensor_readings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;synced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Always Use Batch Inserts
&lt;/h2&gt;

&lt;p&gt;Never insert one row per commit in a logging loop. Each commit flushes a transaction to disk, and on embedded storage that is slow enough to miss readings at any meaningful sample rate. Batch your inserts inside a single transaction — the difference in throughput is dramatic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextmanager&lt;/span&gt;

&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batch_insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Context manager that accumulates rows and commits in one transaction.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;add&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Flush any remaining rows on exit
&lt;/span&gt;        &lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executemany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO sensor_readings (timestamp, topic, payload) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="c1"&gt;# Usage in a sensor loop
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;batch_insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sensor_stream&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sensors/warehouse/temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unit&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;celsius&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;executemany&lt;/code&gt; with a list of tuples is the correct tool here. It prepares the statement once and executes it repeatedly within a single transaction, which is both faster and safer than building SQL strings manually. Never concatenate user data or variable values into SQL strings directly — always use parameterised queries with &lt;code&gt;?&lt;/code&gt; placeholders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thread Safety: One Connection Per Thread
&lt;/h2&gt;

&lt;p&gt;If your application is multi-threaded — and most real embedded applications are — do not share a single SQLite connection across threads. SQLite connections are not thread-safe by default, and sharing one between a logging thread and a sync thread will give you intermittent errors that are hard to reproduce. The cleanest approach is to use a connection pool or simply open a dedicated connection per thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="c1"&gt;# Thread-local storage: each thread gets its own connection automatically
&lt;/span&gt;&lt;span class="n"&gt;_local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return a per-thread SQLite connection, creating it if needed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;conn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_same_thread&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;  &lt;span class="c1"&gt;# Access columns by name
&lt;/span&gt;        &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA journal_mode=WAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA synchronous=NORMAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;row_factory = sqlite3.Row&lt;/code&gt; is small but worth enabling. It makes query results accessible by column name (&lt;code&gt;row['timestamp']&lt;/code&gt;) rather than index (&lt;code&gt;row[0]&lt;/code&gt;), which makes your code substantially easier to read and maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage Storage: Prevent the Database from Growing Forever
&lt;/h2&gt;

&lt;p&gt;On a device with a 16 GB SD card, an unbounded database will eventually fill the storage and crash your application. Build a retention policy in from the start. The simplest approach is a periodic cleanup that deletes synced rows older than your retention window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup_old_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retain_days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Delete synced records older than retain_days.
    Only deletes synced rows — unsynced data is preserved regardless of age,
    because it hasn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t been uploaded yet and may still be needed.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retain_days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM sensor_readings WHERE synced = 1 AND timestamp &amp;lt; ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# VACUUM reclaims the disk space freed by deletions
&lt;/span&gt;        &lt;span class="c1"&gt;# Run this infrequently — it rewrites the entire database file
&lt;/span&gt;        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VACUUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;deleted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;cleanup_old_records&lt;/code&gt; once per day from a background thread or a systemd timer. The &lt;code&gt;VACUUM&lt;/code&gt; command rewrites the database to reclaim the disk space freed by deletions — SQLite does not shrink the file automatically. &lt;code&gt;VACUUM&lt;/code&gt; is expensive, so do not call it every cleanup cycle; once a day or once a week is usually fine depending on your write volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Pattern
&lt;/h2&gt;

&lt;p&gt;Here is what the full setup looks like when these practices are combined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="n"&gt;DB_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/var/lib/myapp/sensor_data.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;_local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;conn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DB_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_same_thread&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA journal_mode=WAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA synchronous=NORMAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;init_schema&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE TABLE IF NOT EXISTS sensor_readings (
            id        INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp REAL    NOT NULL,
            topic     TEXT    NOT NULL,
            payload   TEXT    NOT NULL,
            synced    INTEGER NOT NULL DEFAULT 0
        )
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE INDEX IF NOT EXISTS idx_timestamp_synced
        ON sensor_readings(timestamp, synced)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_reading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO sensor_readings (timestamp, topic, payload) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return unsynced readings, oldest first.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM sensor_readings WHERE synced = 0 ORDER BY timestamp ASC LIMIT ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mark_synced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Mark a batch of rows as successfully uploaded.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executemany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE sensor_readings SET synced = 1 WHERE id = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row_ids&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do these from the start.&lt;/strong&gt; Enable WAL mode and &lt;code&gt;synchronous=NORMAL&lt;/code&gt; immediately after opening each connection. Use parameterised queries — never string concatenation. Add a timestamp index and a &lt;code&gt;synced&lt;/code&gt; column if you are buffering for later upload. Set a retention policy before your first deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid these.&lt;/strong&gt; Do not commit per row in a high-frequency logging loop — batch your inserts. Do not share a connection across threads. Do not call &lt;code&gt;VACUUM&lt;/code&gt; on every cleanup. Do not use the default journal mode if you have concurrent readers and writers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use when you need it.&lt;/strong&gt; &lt;code&gt;executemany&lt;/code&gt; for bulk inserts. &lt;code&gt;row_factory = sqlite3.Row&lt;/code&gt; for readable query results. &lt;code&gt;VACUUM&lt;/code&gt; once a day or week to reclaim space. &lt;code&gt;PRAGMA integrity_check&lt;/code&gt; after a power loss if you want to verify the database is undamaged.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, drop a comment or a ❤️ — and let me know what you'd like to see covered next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>embedded</category>
      <category>python</category>
      <category>database</category>
    </item>
    <item>
      <title>DuckDB vs SQLite: Which one is better?</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Tue, 09 Jun 2026 09:18:52 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/duckdb-vs-sqlite-two-tiny-databases-that-dont-actually-compete-39d1</link>
      <guid>https://dev.to/ranaweerasupun/duckdb-vs-sqlite-two-tiny-databases-that-dont-actually-compete-39d1</guid>
      <description>&lt;p&gt;I spend most of my time somewhere between microcontrollers and dashboards. One week I'm squeezing firmware onto a device with barely any memory to spare, the next I'm staring at a few million sensor readings trying to work out why a gateway in the field keeps misbehaving. So when people line up "DuckDB vs SQLite" as a fight to the death, I always want to gently jump in.&lt;/p&gt;

&lt;p&gt;They're both small. They both run inside your own program instead of off on some server. They both let you work with a single file using SQL. On paper that makes them sound like rivals. But the more I've used them — across embedded work, edge devices, and plain old data crunching — the more they feel like two neighbours who happen to do completely different jobs.&lt;/p&gt;

&lt;p&gt;Let me walk through what I mean, starting from the very beginning.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what "embedded" actually means here
&lt;/h2&gt;

&lt;p&gt;When people hear the word "database," they often picture something big and separate: a program running on its own server somewhere that your app has to connect to over the network, with a username and password, before it can read or write anything. PostgreSQL and MySQL work like that. There's nothing wrong with it — it's how most large web apps run — but it's a lot of moving parts.&lt;/p&gt;

&lt;p&gt;SQLite and DuckDB throw all of that out. There's no separate program to start. Nothing to log into. No server quietly running in the background. The whole database is just an ordinary file sitting on your disk, and the database engine is a small library your code loads in directly. You point it at the file, you run your queries, and that's the whole setup. Nothing else to install or babysit.&lt;/p&gt;

&lt;p&gt;That shared simplicity is the lovely part, and it's exactly why you find these two in places a big server-based database could never go — phones, web browsers, tiny sensors, a quick script on your laptop. But the moment you look at &lt;em&gt;how&lt;/em&gt; each one stores your data and works through it, they head off in opposite directions. That difference is the whole story, so let's go there next.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9hesh2w71jiy0wu9u12l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9hesh2w71jiy0wu9u12l.png" alt="row_vs_column_storage" width="799" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite: the one that's already everywhere
&lt;/h2&gt;

&lt;p&gt;SQLite stores data in rows. Picture a spreadsheet where each row is one complete record, and all the values for that record sit together: the id, the name, the temperature reading, all in one place. That layout is perfect when you're constantly poking at individual records — add this new reading, update that setting, grab the latest entry for device 42. You want the whole record at once, and SQLite hands it to you fast.&lt;/p&gt;

&lt;p&gt;A few things I love about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's tiny. The whole engine is around a megabyte. On a small device, that matters enormously.&lt;/li&gt;
&lt;li&gt;It's everywhere, and I mean everywhere. There are tens of billions of SQLite files in active use — it's running on basically every phone, and your web browser uses it right now to store your history and settings. It has earned the right to be boring.&lt;/li&gt;
&lt;li&gt;It's astonishingly reliable. It's one of the most thoroughly tested pieces of software on the planet. The US Library of Congress even &lt;a href="https://sqlite.org/locrsf.html" rel="noopener noreferrer"&gt;recommends SQLite as a format for preserving digital files long-term&lt;/a&gt;, because they trust it'll still open decades from now. When you're shipping a device that has to run untouched in a cabinet for years, that track record buys you a lot of peace of mind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my embedded work, SQLite is the default for anything that holds &lt;em&gt;state&lt;/em&gt; — the current situation the device needs to remember. Its settings. A queue of readings waiting to be uploaded. A small log of recent events. It handles a steady stream of small writes gracefully and it doesn't ask for resources the hardware doesn't have.&lt;/p&gt;

&lt;p&gt;Where it starts to struggle is heavy number-crunching across huge piles of data. Ask SQLite to add up and average ten million rows and it'll get there — but slowly, because it reads through the data row by row, dragging along every column even when your question only touches one of them. That's not a flaw. It simply wasn't built for that job.&lt;/p&gt;

&lt;h2&gt;
  
  
  DuckDB: the one that makes big analysis feel easy
&lt;/h2&gt;

&lt;p&gt;DuckDB flips the storage layout around. Instead of keeping each row together, it keeps each &lt;em&gt;column&lt;/em&gt; together — all the temperatures in one place, all the device names in another. So when you ask "what's the average temperature across ten million readings," it reads just the temperature column and skips everything else. On top of that it processes data in big batches rather than one row at a time, which modern processors are very good at. The result is that the heavy questions that make SQLite sweat come back from DuckDB before you've finished a sip of coffee.&lt;/p&gt;

&lt;p&gt;There's another part that genuinely changed how I work, and it has nothing to do with speed. DuckDB will read your data files directly, right where they sit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="s1"&gt;'readings/*.parquet'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No loading step. No importing anything into a table first. You point it at a folder of files — CSV, JSON, or Parquet (a compact file format that's common for this kind of data) — and it just reads them. For someone who regularly gets handed a pile of sensor dumps, that's the difference between "give me an afternoon to set up a pipeline" and "give me thirty seconds."&lt;/p&gt;

&lt;p&gt;It also handles data bigger than your computer's memory by spilling the overflow to disk, so a dataset that would crash a normal in-memory tool just... works.&lt;/p&gt;

&lt;p&gt;It's newer than SQLite and the engine is a bit chunkier, but it's still the same idea at heart: a file and a small library, nothing to run separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are these actually used in the real world?
&lt;/h2&gt;

&lt;p&gt;Both, heavily — this isn't a case of betting on something obscure.&lt;/p&gt;

&lt;p&gt;SQLite is &lt;a href="https://www.sqlite.org/mostdeployed.html" rel="noopener noreferrer"&gt;the most widely deployed database engine that exists&lt;/a&gt;, full stop. The tens of billions of copies in daily use make it more common than every other database combined. It's not going anywhere.&lt;/p&gt;

&lt;p&gt;DuckDB is younger but its adoption has shot up. It's pulling in around 37 million downloads a month on Python's package index, it's MIT licensed and free, and it has real commercial backing behind it (a company called MotherDuck builds a cloud service on top while keeping the core engine open and free), which answers the usual worry about whether an open-source tool will still be maintained in five years. It also fits neatly with where the industry is heading: open file formats, modern multi-core processors, and even AI coding assistants, which tend to be good at writing SQL and so reach for DuckDB naturally.&lt;/p&gt;

&lt;p&gt;And SQLite isn't standing still either. Newer spin-offs like Turso/libSQL are adding things like replication and edge support on top of the classic engine. Both of these tools are safe bets.&lt;/p&gt;

&lt;h2&gt;
  
  
  So when does each one actually win?
&lt;/h2&gt;

&lt;p&gt;Here's how it shakes out across the three places I work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On small, constrained hardware&lt;/strong&gt;, SQLite, almost every time. DuckDB's appetite for memory and processing power during a big query is more than a tiny chip wants to give. On a larger edge device running Linux it's a different story, but down at the small end, SQLite's tiny size and long history are hard to argue with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At the edge&lt;/strong&gt; — meaning the gateway boxes that sit between your sensors and the cloud — they actually team up. A pattern I keep coming back to: let SQLite handle the incoming readings on the device, quietly buffering them as they arrive, then let DuckDB do the local number-crunching before anything gets sent upstream. You ship neat summaries instead of the raw firehose, which is kinder to both your bandwidth bill and your cloud costs. They're not competing here. They're a relay team.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx61ysj2a0savsfyzlb8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx61ysj2a0savsfyzlb8a.png" alt="edge_pipeline_sqlite_duckdb" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For sitting down and analysing data&lt;/strong&gt;, DuckDB is the one I reach for. Being able to throw SQL at a folder of files without setting up any heavy machinery is exactly the kind of low-fuss tool the job usually calls for. It has quietly become a go-to for local analysis, and for good reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line I keep in my head
&lt;/h2&gt;

&lt;p&gt;If I had to shrink all of this down to a fridge magnet:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SQLite is for managing data while it's being created and changed. DuckDB is for analysing it once it's all piled up.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same small, no-server spirit — opposite ends of the data's life. SQLite looks after the data as it's coming in and changing. DuckDB shows up later to make sense of the whole pile. Once that clicked for me, the "versus" framing kind of fell apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd6gvjrp5fgtn0o3hc3nb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd6gvjrp5fgtn0o3hc3nb.png" alt="state_vs_analysis_concept" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So if you're choosing between them, the honest answer is often &lt;em&gt;both, for different things&lt;/em&gt;. Work out whether the job in front of you is about handling data as it changes or making sense of a big pile of it, and the choice mostly makes itself.&lt;/p&gt;

&lt;p&gt;If you've wired these two together in your own projects, I'd love to hear how you split the work. I'm always tinkering with my own setup, and the edge folks always seem to have the most interesting war stories.&lt;/p&gt;

</description>
      <category>duckdb</category>
      <category>sqlite</category>
      <category>iot</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Deploying Production Systems on Raspberry Pi: Lessons from the Field</title>
      <dc:creator>Supun Sriyananda</dc:creator>
      <pubDate>Sun, 07 Jun 2026 05:38:31 +0000</pubDate>
      <link>https://dev.to/ranaweerasupun/deploying-production-systems-on-raspberry-pi-lessons-from-the-field-1i4k</link>
      <guid>https://dev.to/ranaweerasupun/deploying-production-systems-on-raspberry-pi-lessons-from-the-field-1i4k</guid>
      <description>&lt;h2&gt;
  
  
  Deploying Production Systems on Raspberry Pi: Lessons from the Field
&lt;/h2&gt;

&lt;p&gt;These are the things I wish I had known before deploying Pis in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  SD Cards Will Kill You
&lt;/h2&gt;

&lt;p&gt;The first production Pi I deployed used a generic microSD card. It failed after four months. The second one used a "name brand" card. It failed after six months. The pattern remained always the same: the filesystem corrupts during a power loss, the Pi boots into read-only mode, and whatever the system was supposed to be doing silently stops working.&lt;/p&gt;

&lt;p&gt;SD card corruption under power loss is not a bug you can fix in software. It is a fundamental characteristic of flash storage that was designed for cameras, not servers. The cells wear out, write operations are not atomic, and a sudden power cut mid-write leaves the filesystem in a state that &lt;strong&gt;fsck (File System Consistency Check)&lt;/strong&gt; sometimes cannot recover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you choose SD cards:&lt;/strong&gt; Switch to a Pi-rated industrial SD card (SanDisk MAX Endurance, Samsung Pro Endurance) or eliminate the SD card entirely by booting from a USB SSD. USB boot on Pi 4 and Pi 5 is stable and the endurance difference is enormous — a decent SSD handles orders of magnitude more write cycles than any SD card.&lt;/p&gt;

&lt;p&gt;For systems that must use SD, mount the filesystem read-only and put all writable state on a &lt;strong&gt;tmpfs&lt;/strong&gt; or a separate partition with journaling.&lt;/p&gt;

&lt;p&gt;tmpfs is a special type of temporary file storage facility in Linux and Unix-like systems that stores files directly in volatile memory (RAM) instead of on a persistent drive like an SD card or SSD.&lt;/p&gt;

&lt;p&gt;When you mount a folder as tmpfs, any files written to that folder behave like regular files, but they consume RAM and exist purely in memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/fstab — mount root read-only, put logs and data elsewhere&lt;/span&gt;
/dev/mmcblk0p2  /        ext4  ro,defaults  0  1
tmpfs           /tmp     tmpfs defaults      0  0
tmpfs           /var/log tmpfs defaults      0  0

&lt;span class="c"&gt;# Separate partition for application data with journaling&lt;/span&gt;
/dev/mmcblk0p3  /var/lib/myapp  ext4  defaults,data&lt;span class="o"&gt;=&lt;/span&gt;journal  0  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a warehouse sensor deployment, the application writes data to SQLite on a separate ext4 partition with journaling enabled. If the Pi loses power mid-write, fsck can recover the journal. The root partition is read-only and survives power loss cleanly every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thermal Throttling Is Silent and Intermittent
&lt;/h2&gt;

&lt;p&gt;The thing is Pi will not tell you that it is throttling. It will not log a warning. That means, your video stream will just start dropping frames, your serial latency will increase, and your MQTT reconnects will take longer. And as you can see, all these symptoms look like software bugs. But if you check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vcgencmd get_throttled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vcgencmd get_throttled&lt;/code&gt; is a command line tool unique to the Raspberry Pi that checks whether the computer has lowered its CPU speed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0x0&lt;/code&gt; means everything is fine. Anything else means the Pi is throttling now or has throttled since last boot. The common values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0x50005&lt;/code&gt; — The danger zone. You are currently under-volted and throttled right now, and it has happened before.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x50000&lt;/code&gt; — This means your system is physically running fine right now, but under-voltage (0x10000) and throttling (0x40000) have occurred in the past. Your current power supply is dropping voltage under load, making your SD card highly vulnerable to corruption.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0x4&lt;/code&gt; — soft temperature limit active&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was building a telepresence robot, encoding 720p30 H.264 while running the aiohttp server and serial communication was pushing the Pi 4 to 80°C without a heatsink. The encoder started dropping frames randomly. Adding a heatsink brought idle temperature to 45°C and load temperature to 62°C. Managed to get the throttling under control.&lt;/p&gt;

&lt;p&gt;On a Pi 5, the situation is better but not solved. The Pi 5 has an active cooler as an official accessory and it is worth using in any deployment where the Pi is in an enclosure. But, enclosures trap heat. A Pi in a plastic project box with no airflow will throttle faster than a bare board.&lt;/p&gt;

&lt;p&gt;Also it is a best practice to add temperature monitoring to your health check endpoint so you find out before users do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_pi_health&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vcgencmd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;measure_temp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# "temp=58.0'C"
&lt;/span&gt;
    &lt;span class="n"&gt;throttled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vcgencmd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get_throttled&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# "throttled=0x0"
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;throttled&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;throttled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;throttled_ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;throttled&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;throttled=0x0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Power Supply Quality Matters More Than Rated Current
&lt;/h2&gt;

&lt;p&gt;A power supply rated for 3A is not always a 3A power supply. Cheap USB-C supplies have poor voltage regulation. So, under a load they sag below the 5.1V the Pi needs and trigger under-voltage throttling. The symptom is identical to thermal throttling: random slowdowns, occasional reboots, SD card corruption on shutdown.&lt;/p&gt;

&lt;p&gt;The official Raspberry Pi power supply is not a premium product — it is a specification-compliant one. Use it, or use a bench power supply for development and a known-good supply for deployment. The Pi 5 draws up to 5A under full load; a 3A supply will cause under-voltage events when the CPU is fully loaded.&lt;/p&gt;

&lt;p&gt;For any deployment running off mains power, a UPS hat (Geekworm UPS hat, Waveshare UPS hat) is worth the £20. The Pi gets notified of incoming power loss and can initiate a clean shutdown before the battery dies, which eliminates the entire class of "power cut during SD write" corruption events.&lt;/p&gt;




&lt;h2&gt;
  
  
  Network time synchronization errors can cause unexpected system failures
&lt;/h2&gt;

&lt;p&gt;A Pi that has been offline for an extended period will have a wrong system clock when it boots — sometimes wrong by days if the RTC battery is dead or there is no RTC at all. Applications that timestamp log entries, certificate validity checks, and SQLite timestamp comparisons all behave unexpectedly when the system time is wrong.&lt;/p&gt;

&lt;p&gt;A specific failure case I encountered: the MQTT client was writing timestamps using &lt;code&gt;datetime.now().isoformat()&lt;/code&gt;. After a boot without internet, the system clock was set to &lt;strong&gt;2023-01-01&lt;/strong&gt; (the default). All queued messages got timestamps in 2023. When the clock corrected to 2024 via NTP after network connection, the retention policy deleted those messages as being "older than 7 days" — because relative to the current time they appeared to be a year old.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1:&lt;/strong&gt; Use a hardware RTC. The DS3231 costs about £3 and keeps accurate time across power cycles without network. Enable it with a device tree overlay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /boot/firmware/config.txt&lt;/span&gt;
&lt;span class="nv"&gt;dtoverlay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;i2c-rtc,ds3231
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix 2:&lt;/strong&gt; For timestamps that survive offline periods, use monotonic time for intervals and NTP-synced time only for absolute timestamps. Do not mix them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 3:&lt;/strong&gt; Configure &lt;strong&gt;chrony&lt;/strong&gt; or &lt;strong&gt;systemd-timesyncd&lt;/strong&gt; to be aggressive about syncing on boot and to accept large time jumps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/chrony.conf
&lt;/span&gt;&lt;span class="err"&gt;makestep&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt; &lt;span class="err"&gt;-1&lt;/span&gt;   &lt;span class="c"&gt;# Accept any step size, any number of times
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Watchdog Timers Are Not Optional
&lt;/h2&gt;

&lt;p&gt;If your Pi is in a location where you cannot physically reach it — mounted on a robot, installed in a warehouse, bolted inside a wall panel — a software crash or infinite loop that freezes the application is effectively a permanent failure until someone intervenes.&lt;/p&gt;

&lt;p&gt;The Linux kernel watchdog kicks the hardware watchdog timer while the kernel is running. If the kernel hangs, the watchdog expires and forces a reboot. But it does not know whether your application is running correctly. For that, you need an application-level watchdog.&lt;/p&gt;

&lt;p&gt;systemd's built-in watchdog support requires almost no code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In your main loop, notify systemd you're still alive
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;notify_watchdog&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Tell systemd the application is healthy.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;systemd-notify WATCHDOG=1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Call this from your main loop — if you stop calling it,
# systemd will restart the service
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;do_work&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;notify_watchdog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/myapp.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;WatchdogSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30        # Restart if no heartbeat for 30 seconds&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;RestartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;120&lt;/span&gt;
&lt;span class="py"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For applications where &lt;code&gt;notify_watchdog()&lt;/code&gt; cannot be called from the main loop (async applications, multi-threaded servers), run it from a background thread that monitors the health of the main thread.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remote Access Must Work Before Anything Breaks
&lt;/h2&gt;

&lt;p&gt;The time to set up remote access is before you deploy, not after. I use Tailscale for most Pi deployments because it takes five minutes to configure, works through NAT, does not require port forwarding, and uses WireGuard under the hood. Once it is running, you have a reliable backdoor to your hardware. However, if you need more control, complete data sovereignty, and zero third-party dependencies, use vanilla WireGuard instead. While WireGuard requires you to manually configure routing rules and host a central server with an open port for NAT traversal, it gives you total ownership over your network topology without device or account limitations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up

&lt;span class="c"&gt;# Enable SSH in your tailnet policy and you can reach the Pi from anywhere&lt;/span&gt;
ssh user@cyrobot.turkey-trench.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailscale also provides valid TLS certificates for your device's hostname in the tailnet — which is how the WebRTC server serves HTTPS without a domain name or public CA.&lt;/p&gt;

&lt;p&gt;Set up &lt;strong&gt;mosh&lt;/strong&gt; alongside SSH for unreliable connections. Regular SSH sessions die when the network hiccups. Mosh sessions survive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Logs Fill Up the Filesystem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/var/log&lt;/code&gt; on a production Pi will fill up over months of continuous operation. When it does, your application cannot write logs, SQLite cannot open its WAL file, and things fail in confusing ways that do not obviously point to "disk full."&lt;/p&gt;

&lt;p&gt;Set up log rotation from day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/logrotate.d/myapp&lt;/span&gt;
/var/log/myapp/&lt;span class="k"&gt;*&lt;/span&gt;.log &lt;span class="o"&gt;{&lt;/span&gt;
    daily
    rotate 7
    compress
    missingok
    notifempty
    size 10M
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And add disk usage to your health check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_disk&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disk_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;free_percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;free_gb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;used_percent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;free_percent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;free_percent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Summary
&lt;/h2&gt;

&lt;p&gt;Before deploying any Pi to a location you cannot easily reach:&lt;/p&gt;

&lt;p&gt;Boot storage is an industrial SD card or USB SSD. The root filesystem is read-only or has journaling on writable partitions. A hardware RTC is installed. A heatsink or active cooler is fitted. A quality power supply is used and a UPS hat is fitted if on mains power. Tailscale is installed and tested from outside the local network. systemd service has &lt;strong&gt;&lt;em&gt;Restart=always&lt;/em&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;em&gt;WatchdogSec&lt;/em&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;em&gt;StartLimitBurst&lt;/em&gt;&lt;/strong&gt; set. Log rotation is configured. A health endpoint exposes temperature, throttle status, and disk usage. You have confirmed you can SSH in and restart the service from the office before going to the deployment site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by Supun Akalanka | Category: Lessons Learned | Tags: Raspberry Pi, Production, Reliability, Embedded Linux, Hardware&lt;/em&gt;&lt;/p&gt;

</description>
      <category>raspberrypi</category>
      <category>deployment</category>
      <category>linux</category>
      <category>reliability</category>
    </item>
  </channel>
</rss>
