<?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: Susumu Takahashi</title>
    <description>The latest articles on DEV Community by Susumu Takahashi (@susumun).</description>
    <link>https://dev.to/susumun</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%2F3961116%2F87a59747-8eb8-43eb-9db6-c160d3592934.JPG</url>
      <title>DEV Community: Susumu Takahashi</title>
      <link>https://dev.to/susumun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/susumun"/>
    <language>en</language>
    <item>
      <title>Code signing basics — what Apple Notarization and Windows Authenticode actually certify</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 09 Sep 2026 23:59:51 +0000</pubDate>
      <link>https://dev.to/susumun/code-signing-basics-what-apple-notarization-and-windows-authenticode-actually-certify-136h</link>
      <guid>https://dev.to/susumun/code-signing-basics-what-apple-notarization-and-windows-authenticode-actually-certify-136h</guid>
      <description>&lt;p&gt;When you build a desktop app for distribution, macOS may greet users with "cannot be opened because the developer cannot be verified," and Windows SmartScreen may show "Windows protected your PC" for an unrecognized publisher. Signing and notarizing an app is how you avoid these warnings, but what exactly does that signature prove — and what does it not prove? The distinction is easy to get wrong. Here's a breakdown of two systems that look similar on the surface but play different roles: Apple's Notarization and Windows' Authenticode.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: Code signing is the umbrella term for attaching a cryptographic signature to an executable so that its author's identity and the integrity of its contents (whether it's been tampered with since signing) can be verified. The OS checks this signature before deciding whether to allow execution or show a warning.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a signature proves — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The key thing to understand is that code signing certifies two things: &lt;strong&gt;identity&lt;/strong&gt; and &lt;strong&gt;integrity&lt;/strong&gt;. It does not certify &lt;strong&gt;safety&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt;: this executable was produced by the developer or organization named in the certificate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt;: the file's contents have not been altered by even a single byte since it was signed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A signed app carries no guarantee that it's free of bugs or vulnerabilities. Signing is a mechanical way to verify "who made this, and has anyone tampered with it since" — it is not a review of whether the software itself is good. Keeping this distinction in mind makes it easier to understand what Notarization and SmartScreen reputation actually add on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apple's model: signing and Notarization are separate layers
&lt;/h2&gt;

&lt;p&gt;macOS Gatekeeper actually checks two independent pieces of information.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Code signing (&lt;code&gt;codesign&lt;/code&gt;)&lt;/strong&gt;: the developer uses the private key tied to an Apple-issued Developer ID certificate to embed a signature locally. That signature includes a hash of the entire app, so any modification after signing breaks verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notarization&lt;/strong&gt;: the signed app is uploaded to Apple's servers, where it's scanned automatically for known malicious patterns. If it passes, Apple issues a "notarization ticket," which gets stapled to the app itself — allowing Gatekeeper to verify it offline, even with no network connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In other words, Notarization is not a replacement for signing — it's an additional layer where Apple independently checks an already-signed app and attaches proof of that check to the app itself. Gatekeeper only allows a silent launch when both a valid signature and a valid notarization ticket are present.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle signing order: inside out
&lt;/h2&gt;

&lt;p&gt;A macOS &lt;code&gt;.app&lt;/code&gt; bundle often contains more than just the main executable — frameworks and helper tools are commonly bundled as separate Mach-O binaries inside it. The signature over the whole bundle is computed from a hash that includes everything inside it, which means &lt;strong&gt;the inner binaries need to be signed individually first, and the outer &lt;code&gt;.app&lt;/code&gt; bundle signed afterward.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reverse that order and the outer signature ends up computed against binaries that were still unsigned at the time — verification will flag a content mismatch. Think of it as: finalize everything inside the box first, then seal the outer lid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows' model: Authenticode and the certificate chain
&lt;/h2&gt;

&lt;p&gt;Windows' Authenticode differs from Apple's Developer ID in how certificates get issued. Apple is the sole issuer of Developer ID certificates, while Windows code-signing certificates are issued by third-party Certificate Authorities (CAs), and the OS trusts them by tracing an unbroken chain up to a trusted root certificate — meaning "this publisher was identity-verified by a CA the system already trusts."&lt;/p&gt;

&lt;p&gt;Certificates broadly fall into two tiers: OV (Organization Validation, confirming the organization exists) and EV (Extended Validation, a stricter identity check). EV certificates once carried an advantage of gaining SmartScreen trust immediately, but that distinction has shifted over time — it's no longer accurate to simply say "EV means instant trust, OV means extra scrutiny."&lt;/p&gt;

&lt;h2&gt;
  
  
  Without a timestamp, an expired certificate drags past signatures down with it
&lt;/h2&gt;

&lt;p&gt;Another detail worth knowing is timestamping — embedding proof from a Timestamp Authority (TSA) at signing time. Skip it, and the moment the signing certificate expires, every binary ever signed with it (including older versions you shipped long ago) can be treated as having an invalid signature.&lt;/p&gt;

&lt;p&gt;Add a timestamp, and a third party has recorded the fact that "this signature was made while the certificate was still valid" — so verification of that signed binary remains valid even after the certificate itself expires. macOS's &lt;code&gt;codesign&lt;/code&gt; has a &lt;code&gt;--timestamp&lt;/code&gt; flag, and Windows signing tools have equivalent timestamp-server options. As a rule, distributed binaries should be signed with a timestamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  SmartScreen reputation is a separate axis from the signature itself
&lt;/h2&gt;

&lt;p&gt;An Authenticode signature only certifies who made the file and whether it's intact — nothing more. Windows SmartScreen layers something else on top: a &lt;strong&gt;reputation&lt;/strong&gt; score for the publisher's certificate, built up from download volume and how long it's been in use.&lt;/p&gt;

&lt;p&gt;That means a correctly and validly signed app can still trigger a SmartScreen warning if the certificate is newly issued and hasn't accumulated any track record yet. This isn't a broken signature — it's simply "trust as a publisher" that hasn't built up yet, and it tends to fade as downloads accumulate over time. A signature-verification failure and a reputation-driven warning are different problems with different causes, and worth telling apart clearly.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it certifies&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code signing (codesign / Authenticode)&lt;/td&gt;
&lt;td&gt;Identity (who made it) and integrity (unaltered)&lt;/td&gt;
&lt;td&gt;Does not certify that the contents are "safe"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple Notarization&lt;/td&gt;
&lt;td&gt;Passed Apple's automated scan&lt;/td&gt;
&lt;td&gt;A separate layer from signing; the stapled ticket enables offline verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle signing order&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Sign inner binaries first, then the outer bundle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate chain (Windows)&lt;/td&gt;
&lt;td&gt;CA-verified publisher identity&lt;/td&gt;
&lt;td&gt;Trust flows from a chain up to a trusted root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the signature was made&lt;/td&gt;
&lt;td&gt;Keeps past signatures valid after the certificate expires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartScreen reputation&lt;/td&gt;
&lt;td&gt;Accumulated track record as a publisher&lt;/td&gt;
&lt;td&gt;A separate axis from whether the signature is valid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rather than "it's signed, so it's safe," it's more accurate to think of it as: signing is a mechanical way to verify identity and integrity, and reputation systems like SmartScreen run alongside it as a separate track-record check. Framing it that way makes the warnings you run into during distribution much easier to reason about.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Running Flask's dev server as a desktop app's backend — what actually matters</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 09 Sep 2026 00:56:48 +0000</pubDate>
      <link>https://dev.to/susumun/running-flasks-dev-server-as-a-desktop-apps-backend-what-actually-matters-2m2e</link>
      <guid>https://dev.to/susumun/running-flasks-dev-server-as-a-desktop-apps-backend-what-actually-matters-2m2e</guid>
      <description>&lt;p&gt;Start a Flask app and the terminal prints a familiar line: "WARNING: This is a development server. Do not use it in a production deployment." Yet plenty of desktop apps bundle that same local Flask server as their actual runtime and keep it running on the user's machine for the life of the session. That looks like ignoring the warning outright, but the underlying assumptions have actually changed. This article works through what has to change for that warning to become safe to set aside — and what you still have to handle yourself, or it turns into a real bug.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: WSGI (Web Server Gateway Interface) is the standard interface between a Python web application and the server that runs it. Flask itself builds the WSGI application; the part that actually accepts and serves HTTP requests is a separate, swappable component. By default, development uses a lightweight built-in server (Werkzeug), while production deployments normally swap in a dedicated production WSGI server such as Gunicorn.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The warning is about an unpredictable crowd of clients
&lt;/h2&gt;

&lt;p&gt;What that warning is really about is a public-facing web service: handling concurrent traffic from an unknown number of clients, minimal built-in hardening, and no multi-worker process model for load distribution. In short, "not strong enough to serve the open internet."&lt;/p&gt;

&lt;p&gt;Using Flask as a desktop app's backend changes that premise entirely. The server binds only to &lt;code&gt;127.0.0.1&lt;/code&gt; (loopback) and is unreachable from any external network. The only client hitting it is a single browser tab running on the same machine — not an unpredictable crowd, but one tab the user opened themselves. Under that condition, most of the dev server's weaknesses simply stop applying.&lt;/p&gt;

&lt;p&gt;The flip side matters just as much: accidentally binding to &lt;code&gt;0.0.0.0&lt;/code&gt; makes the server reachable from any other device on the same LAN, and that premise collapses. When Flask is running as a desktop app's backend, binding strictly to loopback has to be an explicit, deliberate choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn off the auto-reloader — it breeds a second process
&lt;/h2&gt;

&lt;p&gt;Flask's development server has a reloader that watches source files and automatically restarts the process when they change. It's convenient during development, but under the hood it runs as two processes: a parent that watches for file changes, and a child that actually handles requests.&lt;/p&gt;

&lt;p&gt;Leaving that enabled in a packaged desktop app causes real problems. The port number claimed at startup, and the "an instance is already running" marker file written to prevent double-launches, are both normally designed around the assumption that exactly one process is running at a time. Once the reloader spawns a parent/child pair, that assumption breaks, opening the door to duplicate port claims and confused process tracking.&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;# Prioritize the guarantee of a single process over reload convenience
&lt;/span&gt;&lt;span class="n"&gt;app&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="n"&gt;port&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="n"&gt;use_reloader&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;threaded&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;use_reloader=False&lt;/code&gt; explicitly guarantees the app always starts as a single process. Since a desktop app's source code never changes while it's running, the reloader's whole reason for existing doesn't really apply here anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set &lt;code&gt;threaded=True&lt;/code&gt; explicitly — or concurrent access will stall
&lt;/h2&gt;

&lt;p&gt;Unless told otherwise, Flask's development server handles requests one at a time, in sequence. But once the UI lives in a browser, it's completely normal for multiple requests to be in flight simultaneously. Picture a long-running request streaming maintenance progress alongside a short heartbeat request sent every few dozen seconds to confirm the browser tab is still alive — while the server is busy serving the long request, the short one just sits in a queue.&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;# Without threaded=True, a short request can get stuck behind
# a long-running one instead of being served concurrently
&lt;/span&gt;&lt;span class="n"&gt;app&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="n"&gt;port&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="n"&gt;use_reloader&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;threaded&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;threaded=True&lt;/code&gt; makes each request get handled on its own thread, sidestepping that queuing problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrent threads mean you now have to guard shared state
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;threaded=True&lt;/code&gt; lets multiple requests run at the same time, a different problem shows up: multiple request handlers can now read and write the same global variable concurrently.&lt;/p&gt;

&lt;p&gt;Say there's a global variable tracking "is a maintenance job currently running." If one request is in the middle of a two-step "check whether one is running, and if not, start a new one" sequence, and another thread reads and writes that same variable at the same moment, both can conclude "nothing is running" and each kick off a job — a classic check-and-set race condition.&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;threading&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Lock&lt;/span&gt;

&lt;span class="n"&gt;_maint_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;_maint_process&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_maintenance&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;_maint_lock&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;_maint_process&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&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;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# already running
&lt;/span&gt;        &lt;span class="c1"&gt;# start the new process while still holding the lock
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is straightforward: protect the shared variable with a &lt;code&gt;threading.Lock&lt;/code&gt; so the "check, then write" sequence becomes one indivisible operation. It's worth noting this operates at a different layer than &lt;a href="https://en.wpmm.jp/blog/python-file-locking-fcntl-vs-msvcrt/" rel="noopener noreferrer"&gt;cross-platform file locking with fcntl/msvcrt&lt;/a&gt;, which guards against races between separate &lt;em&gt;processes&lt;/em&gt;. This is about races between separate &lt;em&gt;threads inside the same process&lt;/em&gt; — and once you choose &lt;code&gt;threaded=True&lt;/code&gt;, guarding against that becomes mandatory, not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  You own port allocation and duplicate-instance detection
&lt;/h2&gt;

&lt;p&gt;In a production application server, controlling multiple instances and negotiating ports is usually somebody else's job — a process manager like systemd, or a container orchestrator. A Flask backend embedded in a desktop app has no such external supervisor, so you have to build that logic yourself.&lt;/p&gt;

&lt;p&gt;Hardcoding a fixed port makes a simple failure very likely: launch the app twice, and the second attempt can't bind because the first process is still holding the port. Instead, scan for a free port at startup and write the chosen port number and process ID to a marker file, so the next launch can check whether an instance is already running.&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;socket&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_free_port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5100&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;port&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="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&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;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_STREAM&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;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;'&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;OSError&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;No free port found in range &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&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;p&gt;When an existing instance is detected, rather than just failing with an error, it's worth reopening a browser tab pointed at that instance's port and quietly exiting. From the user's point of view, clicking the app icon a second time just brings the original screen back — which is the behavior people expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server has no way of knowing a browser tab was closed
&lt;/h2&gt;

&lt;p&gt;With a native windowed app, the OS notifies the app the instant the user closes its window. But when a browser tab is the UI, closing that tab gives the backend Flask process no signal whatsoever. Without any countermeasure, the backend process just keeps running in the background even after the user closes the tab.&lt;/p&gt;

&lt;p&gt;A periodic "heartbeat" request from the browser fills that gap.&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;_last_heartbeat_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&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/heartbeat&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&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;POST&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;api_heartbeat&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_last_heartbeat_time&lt;/span&gt;
    &lt;span class="n"&gt;_last_heartbeat_time&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser hits this endpoint every few dozen seconds, and a background thread on the server periodically checks how long it's been since the last heartbeat arrived. Once the tab closes, the heartbeats stop, and after a set period of silence the server treats that as "the user closed the tab" and shuts itself down. Guarding against shutting down mid-maintenance-job, and excluding the initial warm-up window while the browser is still connecting, both matter for avoiding false positives in real use.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting / mechanism&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bind to &lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Blocks reachability from any external network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;use_reloader=False&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guarantees a single running process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;threaded=True&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps a long request from stalling short ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guard shared state with &lt;code&gt;threading.Lock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Prevents check-and-set races between threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan for a free port + record the running instance&lt;/td&gt;
&lt;td&gt;Detects and avoids duplicate launches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heartbeat monitoring + timeout self-shutdown&lt;/td&gt;
&lt;td&gt;Indirectly detects that the tab was closed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flask's development server isn't something to avoid outright — it's easier to work with once you understand exactly what assumptions that warning is built on, and confine its use to environments where those assumptions genuinely hold. Loopback-only binding, a single process, thread safety, duplicate-launch detection, and liveness checks: handle each of these deliberately, and the dev server becomes a stable enough foundation to run a desktop app's backend on.&lt;/p&gt;

&lt;p&gt;The design decision behind choosing a Flask-plus-browser structure in the first place is covered separately in &lt;a href="https://en.wpmm.jp/blog/local-flask-desktop-architecture/" rel="noopener noreferrer"&gt;why we built a desktop app on local Flask + browser UI&lt;/a&gt;. This article picks up from there, covering the operational details worth handling once that structure has already been chosen.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Atomic writes — how tempfile + os.replace prevent corrupted JSON</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 08 Sep 2026 00:19:18 +0000</pubDate>
      <link>https://dev.to/susumun/atomic-writes-how-tempfile-osreplace-prevent-corrupted-json-2lo8</link>
      <guid>https://dev.to/susumun/atomic-writes-how-tempfile-osreplace-prevent-corrupted-json-2lo8</guid>
      <description>&lt;p&gt;What happens if the power cuts out while a process is writing to a config file? Or if antivirus software on Windows briefly locks a file mid-write? If you naively overwrite a file with &lt;code&gt;open(path, 'w')&lt;/code&gt;, whatever partial content existed at the moment of interruption is what remains on disk. For JSON, that usually means broken syntax — &lt;code&gt;json.load()&lt;/code&gt; throws on the next startup, and the entire configuration is effectively lost. This article walks through a standard technique for preventing that: writing to a temporary file first, then swapping it in atomically.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: "Atomic" here means an operation either completes entirely or doesn't happen at all — there's no partial, observable in-between state. It's the same sense of the word used for database transactions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why direct overwrites are dangerous
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;open(path, 'w')&lt;/code&gt; effectively truncates the file first and then writes the new content. If the process is interrupted during that window, the file is left empty or holding incomplete content.&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;# Dangerous: a crash mid-write leaves a corrupted file behind
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;config.json&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;w&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;f&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;dump&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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# what if this gets interrupted?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The causes vary: a &lt;code&gt;kill -9&lt;/code&gt;, a power outage, antivirus software briefly blocking file access on Windows, or a backup tool grabbing the file mid-write. This rarely reproduces during local development, but in a long-running production environment, it will eventually happen with near certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: write to a temp file, then swap it in
&lt;/h2&gt;

&lt;p&gt;The core idea is simple. Never touch the target file directly. Write the complete new content to a temporary file first, confirm that write fully succeeded, and only then replace the target file with that temp file.&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;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;atomic_write_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&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="n"&gt;dirpath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abspath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&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="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkstemp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dirpath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.json.tmp&lt;/span&gt;&lt;span class="sh"&gt;'&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;with&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;fdopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&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;dump&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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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="n"&gt;ensure_ascii&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filepath&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tempfile.mkstemp()&lt;/code&gt; generates a collision-free temporary filename and returns its file descriptor. The &lt;code&gt;dir=dirpath&lt;/code&gt; argument matters here: placing the temp file in the &lt;strong&gt;same directory&lt;/strong&gt; as the target file ensures the following &lt;code&gt;os.replace()&lt;/code&gt; call stays within a single filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;os.replace()&lt;/code&gt; is atomic
&lt;/h2&gt;

&lt;p&gt;The core of this pattern is the final &lt;code&gt;os.replace(tmp_path, filepath)&lt;/code&gt; call. Python's official documentation states that &lt;code&gt;os.replace()&lt;/code&gt; "will be an atomic operation on Unix" and behaves atomically on Windows as well, as long as both paths are on the same filesystem.&lt;/p&gt;

&lt;p&gt;On POSIX systems (Linux/macOS), this maps to the &lt;code&gt;rename(2)&lt;/code&gt; system call. At the kernel level, swapping a directory entry is a single indivisible operation. There is no intermediate state to observe — from the outside, the file is either in its pre-swap or post-swap state, never something in between.&lt;/p&gt;

&lt;p&gt;On Windows, Python 3.3+ implements this so that overwriting an existing file is handled atomically (roughly equivalent to calling &lt;code&gt;MoveFileEx&lt;/code&gt; with the &lt;code&gt;MOVEFILE_REPLACE_EXISTING&lt;/code&gt; flag).&lt;/p&gt;

&lt;p&gt;Thanks to this property, if the process dies in the middle of &lt;code&gt;json.dump()&lt;/code&gt;, only the unnamed temp file is affected — the real config file remains untouched in whatever valid state it was in before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forcing persistence to disk with &lt;code&gt;os.fsync()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Even with an atomic &lt;code&gt;os.replace()&lt;/code&gt;, there's a subtler risk: the content written by &lt;code&gt;json.dump()&lt;/code&gt; might still be sitting in the OS page cache rather than physically on disk when power is lost. In that case the rename itself could complete, but the renamed file's contents might not reflect what was actually written. &lt;code&gt;os.fsync()&lt;/code&gt; addresses this.&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;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&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;dump&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="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&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;indent&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;f&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&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;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileno&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;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filepath&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;f.flush()&lt;/code&gt; only pushes Python's internal buffer out to the OS — it may still sit in OS-level cache. &lt;code&gt;os.fsync()&lt;/code&gt; goes a step further and asks the OS to wait until the data is actually written to physical disk. Combining both steps ensures the temp file's content is durably on disk by the time &lt;code&gt;os.replace()&lt;/code&gt; runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up after a failed write
&lt;/h2&gt;

&lt;p&gt;If the write to the temp file itself fails partway through (disk full, permission error, etc.), &lt;code&gt;os.replace()&lt;/code&gt; is never reached, so the target file is unaffected. But the half-written temp file is left behind on disk. Left unattended, these accumulate as clutter, so it's worth deleting them explicitly on failure.&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;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&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;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nested &lt;code&gt;try&lt;/code&gt; accounts for the possibility that &lt;code&gt;os.unlink()&lt;/code&gt; itself fails (already deleted, no permission, etc.). Re-raising the original exception (&lt;code&gt;raise&lt;/code&gt;) ensures the caller still learns that the write failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarding against leftover &lt;code&gt;.tmp&lt;/code&gt; files
&lt;/h2&gt;

&lt;p&gt;Since &lt;code&gt;os.replace()&lt;/code&gt; consumes the temp filename and swaps it into the target name on success, &lt;code&gt;.tmp&lt;/code&gt; files normally don't linger under regular operation. But in an extreme edge case — the process gets &lt;code&gt;kill -9&lt;/code&gt;'d in the narrow window right after the temp file is fully written but right before &lt;code&gt;os.replace()&lt;/code&gt; is called — a stray &lt;code&gt;.tmp&lt;/code&gt; file could theoretically be left behind.&lt;/p&gt;

&lt;p&gt;This leftover doesn't threaten data integrity (the real file stays intact), so it's not urgent. But left unchecked over a long-running install, it quietly wastes disk space. A simple startup routine that scans the config directory for &lt;code&gt;.tmp&lt;/code&gt; files older than some threshold (say, one hour) and deletes them closes this gap.&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_stale_tmp_files&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stale_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&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;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&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;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.tmp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;stale_seconds&lt;/span&gt;&lt;span class="p"&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;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tempfile.mkstemp(dir=same directory as target)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps the eventual swap within one filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write to temp file + &lt;code&gt;flush()&lt;/code&gt; + &lt;code&gt;fsync()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Guarantees content is durably on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;os.replace(temp, target)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Makes the swap itself indivisible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete the temp file on failure&lt;/td&gt;
&lt;td&gt;Avoids leaving half-written garbage behind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sweep stale &lt;code&gt;.tmp&lt;/code&gt; files on startup&lt;/td&gt;
&lt;td&gt;Covers the extreme edge-case leftover&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Replacing "write directly to the target file" with "write completely to a temp file, then atomically rename it" eliminates the observable in-between state entirely — and with it, the whole class of bugs where a config file ends up corrupted. The implementation overhead is modest, so it's worth adopting from the start for any file you read and write frequently, like config or cache files.&lt;/p&gt;

&lt;p&gt;This shares a family resemblance with another kind of pitfall that stays invisible during Mac development and only surfaces in real-world operation: &lt;a href="https://en.wpmm.jp/blog/python-file-locking-fcntl-vs-msvcrt/" rel="noopener noreferrer"&gt;cross-platform file locking in Python&lt;/a&gt;, which handles mutual exclusion between multiple processes. Atomic writes and file locking are actually complementary — in an environment where multiple processes might write concurrently, it's worth combining both.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Cross-platform file locking in Python — fcntl vs msvcrt from scratch</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 07 Sep 2026 02:41:31 +0000</pubDate>
      <link>https://dev.to/susumun/cross-platform-file-locking-in-python-fcntl-vs-msvcrt-from-scratch-19c5</link>
      <guid>https://dev.to/susumun/cross-platform-file-locking-in-python-fcntl-vs-msvcrt-from-scratch-19c5</guid>
      <description>&lt;p&gt;What happens when a GUI app and a separate background process both try to write to the same configuration file at the same time? If the timing is bad, one write clobbers the other, and in the worst case the file ends up corrupted. File locking is the standard answer to this "concurrent writes from multiple processes" problem. Trying to implement it in pure Python across both Unix-like systems and Windows runs straight into a wall: the two platforms expose completely different APIs. This article walks through that difference from the ground up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: "lock" here means inter-process locking — coordinating multiple separate processes on the same machine. That's different from locking between threads inside a single process (&lt;code&gt;threading.Lock&lt;/code&gt;), which is a separate topic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why avoid a third-party library
&lt;/h2&gt;

&lt;p&gt;Well-established packages like &lt;code&gt;filelock&lt;/code&gt; exist and work fine. But there are situations where adding a dependency is a real cost — packaging a desktop app with PyInstaller or similar, for instance, where every extra dependency adds build complexity and distribution size. Using only the standard library's &lt;code&gt;fcntl&lt;/code&gt; (Unix-like) and &lt;code&gt;msvcrt&lt;/code&gt; (Windows) modules, you can implement inter-process locking with zero additional dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unix-like systems: &lt;code&gt;fcntl.flock&lt;/code&gt; is an advisory lock
&lt;/h2&gt;

&lt;p&gt;The most important property of &lt;code&gt;fcntl.flock()&lt;/code&gt; on Unix-like systems (Linux/macOS) is that it's an &lt;strong&gt;advisory lock&lt;/strong&gt;. The OS kernel tracks the lock state, but it does not forcibly stop a process that ignores the lock and writes anyway. The lock only works if &lt;strong&gt;every participating process cooperates and follows the same acquisition protocol&lt;/strong&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;fcntl&lt;/span&gt;

&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target.lock&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;w&lt;/span&gt;&lt;span class="sh"&gt;'&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;# LOCK_EX: exclusive lock / LOCK_NB: non-blocking (fail immediately instead of waiting)
&lt;/span&gt;    &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_EX&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_NB&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;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# another process already holds the lock
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;flock()&lt;/code&gt; without &lt;code&gt;LOCK_NB&lt;/code&gt; blocks in place until the lock becomes available. If a GUI app calls this unguarded on its main thread, the interface can appear to freeze. Most implementations instead try non-blocking acquisition, and if it fails, wait briefly and retry — a polling approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows: &lt;code&gt;msvcrt.locking&lt;/code&gt; locks an explicit byte range
&lt;/h2&gt;

&lt;p&gt;Windows has no &lt;code&gt;fcntl&lt;/code&gt; module. The equivalent is &lt;code&gt;msvcrt.locking()&lt;/code&gt;, which locks an explicit byte range within a file.&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;msvcrt&lt;/span&gt;

&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target.lock&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;r+b&lt;/span&gt;&lt;span class="sh"&gt;'&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;# LK_NBLCK: attempt a non-blocking lock (over 1 byte)
&lt;/span&gt;    &lt;span class="n"&gt;msvcrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileno&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;msvcrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LK_NBLCK&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;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# another process already holds the lock
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;fcntl.flock()&lt;/code&gt; locks the whole file, &lt;code&gt;msvcrt.locking()&lt;/code&gt; operates on a specified number of bytes from the current position. When the lock target is a purpose-built sidecar file, as described below, locking just 1 byte is enough — there's no need to manage complex byte-range bookkeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core abstraction: branch on &lt;code&gt;sys.platform&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Wrapping both APIs into a single class comes down to a simple branch on &lt;code&gt;sys.platform&lt;/code&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;sys&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;win32&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;msvcrt&lt;/span&gt;
    &lt;span class="n"&gt;msvcrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msvcrt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LK_NBLCK&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;
    &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_EX&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_NB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The imports are placed inside the branch rather than at module top level because &lt;code&gt;import fcntl&lt;/code&gt; raises &lt;code&gt;ModuleNotFoundError&lt;/code&gt; immediately on Windows. Importing only within the branch that will actually run lets both platform's code live in the same file without either one breaking the other's import.&lt;/p&gt;

&lt;h2&gt;
  
  
  A design choice: don't lock the target file itself
&lt;/h2&gt;

&lt;p&gt;The naive approach is to open and lock the actual file you're writing to (say, &lt;code&gt;sites.json&lt;/code&gt;) directly. But this runs into a Windows-specific pitfall: when one process has a file open in an exclusive mode, Windows can refuse even basic operations from other processes — including reads or opens in a different mode. Having the locking mechanism itself get in the way of writing the data defeats the purpose.&lt;/p&gt;

&lt;p&gt;A common way around this is to introduce a disposable sidecar file, &lt;code&gt;&amp;lt;target-file-name&amp;gt;.lock&lt;/code&gt;, and make &lt;strong&gt;that&lt;/strong&gt; the sole object of locking. The actual data file (&lt;code&gt;sites.json&lt;/code&gt;) is never opened for locking purposes at all — acquiring and releasing the lock is entirely a matter of creating and deleting &lt;code&gt;sites.json.lock&lt;/code&gt;. This separation keeps platform-specific file-open quirks from ever touching the actual data I/O.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making lock acquisition atomic with &lt;code&gt;O_CREAT | O_EXCL&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Creating the sidecar file has its own race condition to worry about. If two processes each perform "check whether the file exists, then create it if not" as two separate steps, another process can slip in between the check and the creation. The fix is the combination of flags passed to &lt;code&gt;os.open()&lt;/code&gt;: &lt;code&gt;O_CREAT | O_EXCL&lt;/code&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;os&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;fd&lt;/span&gt; &lt;span class="o"&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;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target.lock&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_CREAT&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_EXCL&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_RDWR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mo"&gt;0o644&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# reaching this line means this process created the file — lock acquired
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;FileExistsError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# another process already created it — lock acquisition failed
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;O_EXCL&lt;/code&gt; set, "create the file if it doesn't exist, otherwise fail" runs as a single atomic operation inside the OS kernel. There is no window between the check and the creation for another process to interleave, so the race condition is prevented without any additional locking layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remaining gap: stale locks
&lt;/h2&gt;

&lt;p&gt;If the process holding a lock crashes instead of exiting cleanly, the sidecar file can be left behind indefinitely, and no other process will ever be able to acquire the lock again. One practical mitigation is to check the sidecar file's modification time: if it's older than some threshold (say, 30 minutes), treat it as stale, delete it, and retry acquisition.&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="n"&gt;mtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target.lock&lt;/span&gt;&lt;span class="sh"&gt;'&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;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="n"&gt;mtime&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# older than 30 minutes: treat as stale
&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;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target.lock&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# retry acquisition with O_CREAT | O_EXCL after deleting
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach isn't perfect — there's no way to distinguish "a lock genuinely still held by a process that's just been running for a long time" from "a lock abandoned by a crash." In practice, you mitigate this by choosing a threshold generously longer than how long the protected operation normally takes to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping it as a context manager for &lt;code&gt;with&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Bring together everything above — the platform branch, atomic file creation, staleness check, and timeout-with-retry — into a single class, and implement &lt;code&gt;__enter__&lt;/code&gt; / &lt;code&gt;__exit__&lt;/code&gt;. Callers then get a clean interface:&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;with&lt;/span&gt; &lt;span class="nc"&gt;FileLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sites.json&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="c1"&gt;# this block is exclusive across processes
&lt;/span&gt;    &lt;span class="nf"&gt;write_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sites.json&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="c1"&gt;# lock is released automatically on leaving the block
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As long as &lt;code&gt;__exit__&lt;/code&gt; always releases the lock and removes the sidecar file, the lock is guaranteed to be released both on normal completion and when an exception is raised inside the &lt;code&gt;with&lt;/code&gt; block — the exact guarantee a context manager exists to provide, without writing &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt; by hand every time.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Unix-like&lt;/th&gt;
&lt;th&gt;Windows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fcntl.flock()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;msvcrt.locking()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock nature&lt;/td&gt;
&lt;td&gt;Advisory (not enforced)&lt;/td&gt;
&lt;td&gt;Explicit byte-range lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-blocking flag&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LOCK_NB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LK_NBLCK&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock granularity&lt;/td&gt;
&lt;td&gt;Whole file&lt;/td&gt;
&lt;td&gt;Specified byte range&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;fcntl&lt;/code&gt; and &lt;code&gt;msvcrt&lt;/code&gt; differ in both design philosophy and API shape, but with the surrounding design choices in place — locking a disposable sidecar file rather than the data file itself, and using &lt;code&gt;O_CREAT | O_EXCL&lt;/code&gt; for atomic acquisition — the platform branch itself collapses to just a few lines. Worth considering the next time a cross-platform tool needs inter-process locking without pulling in an extra dependency.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://en.wpmm.jp/blog/build-gate-cp932-crash-windows-fix/" rel="noopener noreferrer"&gt;cp932 build-gate crash&lt;/a&gt; is the same shape of problem in a different corner: an encoding pitfall that never reproduces during development on macOS and only surfaces on Windows. Both cases come down to the same lesson — platform differences that stay invisible until the code actually runs where the difference matters.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Crontab syntax basics, and how it differs from WP-Cron</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sat, 05 Sep 2026 02:38:07 +0000</pubDate>
      <link>https://dev.to/susumun/crontab-syntax-basics-and-how-it-differs-from-wp-cron-38ap</link>
      <guid>https://dev.to/susumun/crontab-syntax-basics-and-how-it-differs-from-wp-cron-38ap</guid>
      <description>&lt;p&gt;"What does &lt;code&gt;*/5 * * * *&lt;/code&gt; actually mean?" — anyone setting up a scheduled task on a server has probably stared at that row of asterisks at some point. If you run WordPress, you may already be familiar with &lt;a href="https://en.wpmm.jp/blog/wp-cli-cron-inspect-and-run/" rel="noopener noreferrer"&gt;inspecting WP-Cron's internals through WP-CLI&lt;/a&gt;, but the actual crontab syntax used by the OS itself is something many people never learn properly. Before you can switch WP-Cron over to a real OS-level cron job, you need to understand that syntax first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: crontab is a scheduling mechanism built into Unix-like systems (Linux, macOS, etc.). The name is short for "cron table" — a configuration file, and the command used to manage it, that lists what to run and when, one line at a time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The five time fields
&lt;/h2&gt;

&lt;p&gt;Each line in a crontab consists of five time fields followed by the command to run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;minute hour day month weekday command
*      *    *   *     *       command
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;minute&lt;/td&gt;
&lt;td&gt;0-59&lt;/td&gt;
&lt;td&gt;Which minute to run at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hour&lt;/td&gt;
&lt;td&gt;0-23&lt;/td&gt;
&lt;td&gt;Which hour to run at (24-hour clock)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;day&lt;/td&gt;
&lt;td&gt;1-31&lt;/td&gt;
&lt;td&gt;Which day of the month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;month&lt;/td&gt;
&lt;td&gt;1-12&lt;/td&gt;
&lt;td&gt;Which month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weekday&lt;/td&gt;
&lt;td&gt;0-7 (0 and 7 both mean Sunday)&lt;/td&gt;
&lt;td&gt;Which day of the week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;*&lt;/code&gt; is a wildcard meaning "every value" for that field. A line where all five fields are &lt;code&gt;*&lt;/code&gt; runs every single minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common patterns worth memorizing
&lt;/h2&gt;

&lt;p&gt;In practice, a handful of combinations cover almost everything you'll need.&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;# Run every minute&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;

&lt;span class="c"&gt;# Run every 5 minutes&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;

&lt;span class="c"&gt;# Run daily at 3:00 AM&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;

&lt;span class="c"&gt;# Run at 9:00 AM on weekdays (Mon-Fri)&lt;/span&gt;
0 9 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 1-5 &lt;span class="nb"&gt;command&lt;/span&gt;

&lt;span class="c"&gt;# Run at midnight on the 1st of every month&lt;/span&gt;
0 0 1 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slash notation in &lt;code&gt;*/5&lt;/code&gt; is called a step value — "starting from 0, every 5 units." The hyphen in &lt;code&gt;1-5&lt;/code&gt; is a range, covering Monday(1) through Friday(5). You can also list specific values with commas, like &lt;code&gt;1,3,5&lt;/code&gt;. Once you're comfortable combining steps, ranges, and comma lists, most schedules become expressible.&lt;/p&gt;

&lt;p&gt;Some cron implementations also support shorthand strings like &lt;code&gt;@daily&lt;/code&gt; (equivalent to &lt;code&gt;0 0 * * *&lt;/code&gt;) or &lt;code&gt;@hourly&lt;/code&gt; (equivalent to &lt;code&gt;0 * * * *&lt;/code&gt;). They read more clearly, but support varies by implementation, so it's worth treating the five-field syntax as the portable baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing with &lt;code&gt;crontab -e&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Jobs that run under your own user account are managed with &lt;code&gt;crontab -e&lt;/code&gt;.&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;# Edit the current user's crontab&lt;/span&gt;
crontab &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="c"&gt;# List the current user's crontab&lt;/span&gt;
crontab &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Remove the current user's crontab entirely&lt;/span&gt;
crontab &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;crontab -e&lt;/code&gt; opens whatever text editor is set in your &lt;code&gt;EDITOR&lt;/code&gt; environment variable. Changes take effect as soon as you save — no restart or reload step is needed. Because &lt;code&gt;crontab -r&lt;/code&gt; wipes everything at once, it's worth making a habit of running &lt;code&gt;crontab -l&lt;/code&gt; first to confirm what you're about to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PATH environment trap
&lt;/h2&gt;

&lt;p&gt;Jobs run by cron execute with a much smaller PATH environment variable than your interactive login shell. A command that works fine when you type it yourself can fail with "command not found" once it's running from cron — this is one of the most common causes of "it works when I run it manually, but not from cron."&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;# Works in a login shell, but may not be found in cron's minimal PATH&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt;

&lt;span class="c"&gt;# Fix: specify the full path to the binary&lt;/span&gt;
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/html

&lt;span class="c"&gt;# Or set PATH explicitly at the top of the crontab file&lt;/span&gt;
&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/bin:/usr/bin:/bin
0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;which wp&lt;/code&gt; (or the equivalent for whatever command you're scheduling) ahead of time and writing the full path into the crontab entry is the most reliable fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this differs fundamentally from WP-Cron
&lt;/h2&gt;

&lt;p&gt;The OS-level crontab covered above and WordPress's built-in WP-Cron share a name, but they work on completely different principles.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;OS crontab&lt;/th&gt;
&lt;th&gt;WP-Cron&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What triggers execution&lt;/td&gt;
&lt;td&gt;The OS cron daemon, watching the clock&lt;/td&gt;
&lt;td&gt;Every page load, when WordPress checks for overdue tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low-traffic periods&lt;/td&gt;
&lt;td&gt;Still runs on schedule&lt;/td&gt;
&lt;td&gt;Doesn't run — the task waits for the next visitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What actually runs it&lt;/td&gt;
&lt;td&gt;An OS process, independent of the web server&lt;/td&gt;
&lt;td&gt;A PHP process, running as part of handling a request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the schedule lives&lt;/td&gt;
&lt;td&gt;Edited via &lt;code&gt;crontab -e&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stored in the &lt;code&gt;wp_options&lt;/code&gt; table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason WP-Cron is often described as "a pseudo-cron that depends on traffic" comes straight from the first row of that table. A scheduled post that doesn't go live on time on a low-traffic site late at night is a direct symptom of that design constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to a real crontab entry
&lt;/h2&gt;

&lt;p&gt;Adding &lt;code&gt;define('DISABLE_WP_CRON', true);&lt;/code&gt; to &lt;code&gt;wp-config.php&lt;/code&gt; stops WP-Cron's automatic page-load trigger. From there, an OS crontab entry that periodically invokes WP-CLI gives you execution that no longer depends on visitor traffic.&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;# Process any overdue WP-Cron tasks every 5 minutes&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/wp cron event run &lt;span class="nt"&gt;--due-now&lt;/span&gt; &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/html/wordpress &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt; discards both standard output and standard error. Cron's default behavior is to try emailing you the output of every job, and on a server without mail configured, that can fail silently and repeatedly — appending this redirect avoids it. If you'd rather keep a record, point it at a log file instead of &lt;code&gt;/dev/null&lt;/code&gt;.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;Syntax / command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run every 5 minutes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*/5 * * * *&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run at 9 AM on weekdays&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9 * * 1-5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit your crontab&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crontab -e&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List your crontab&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crontab -l&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avoid PATH issues&lt;/td&gt;
&lt;td&gt;Use the full path to the command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discard output&lt;/td&gt;
&lt;td&gt;Append &lt;code&gt;&amp;gt; /dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The five-field crontab syntax itself is simple, but the differences from an interactive shell — a minimal PATH, and cron's habit of trying to email job output — are where people usually get tripped up. Combined with &lt;a href="https://en.wpmm.jp/blog/wp-cli-cron-inspect-and-run/" rel="noopener noreferrer"&gt;the WP-CLI commands for inspecting WP-Cron's internals&lt;/a&gt;, this gives you a way to run WordPress's scheduled tasks through the OS's own reliable scheduler instead.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Organizing per-host settings with `~/.ssh/config` — a standard practice for anyone managing multiple servers</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 04 Sep 2026 00:12:03 +0000</pubDate>
      <link>https://dev.to/susumun/organizing-per-host-settings-with-sshconfig-a-standard-practice-for-anyone-managing-1kom</link>
      <guid>https://dev.to/susumun/organizing-per-host-settings-with-sshconfig-a-standard-practice-for-anyone-managing-1kom</guid>
      <description>&lt;p&gt;If you maintain WordPress sites across more than a couple of servers, you've probably typed a command like &lt;code&gt;ssh -i ~/.ssh/xxx_key.pem -p 2222 user@203.0.113.10&lt;/code&gt; more times than you'd like. Remembering the right key path, port number, and username for each server isn't realistic, and copying a similar-looking command from shell history is exactly how you end up connecting to the wrong box. &lt;code&gt;~/.ssh/config&lt;/code&gt; solves this by letting you collect per-host settings in one file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;~/.ssh/config&lt;/code&gt; is a file read by the OpenSSH client — it's not a server-side setting. It lives on your local machine (Mac/Linux/Windows SSH client) and teaches it how to reach each server, typically with just a few lines per host.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Basic structure
&lt;/h2&gt;

&lt;p&gt;A minimal block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; myserver
    &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.0.113.10
    &lt;span class="k"&gt;User&lt;/span&gt; deploy
    &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt;
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/myserver_key
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once saved, you can connect with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh myserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Host&lt;/code&gt; here is an alias — an arbitrary string, unrelated to the actual hostname. &lt;code&gt;scp&lt;/code&gt; and &lt;code&gt;rsync&lt;/code&gt; can use the same alias, so &lt;code&gt;scp file.txt myserver:/tmp/&lt;/code&gt; just works. You no longer need to pass the port or username on the command line every time, and there's no chance of mistyping the key path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;IdentitiesOnly yes&lt;/code&gt; matters
&lt;/h2&gt;

&lt;p&gt;Specifying &lt;code&gt;IdentityFile&lt;/code&gt; alone isn't quite enough. Without &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;, the OpenSSH client will offer the specified key &lt;strong&gt;plus&lt;/strong&gt; any other keys registered with &lt;code&gt;ssh-agent&lt;/code&gt;. If the server enforces a limit on authentication attempts (&lt;code&gt;MaxAuthTries&lt;/code&gt;), or runs something like fail2ban that temporarily blocks an IP after repeated failed auth attempts, offering several unintended keys before reaching the correct one can get your connection blocked before it ever succeeds.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; restricts the client to only the key explicitly listed in that &lt;code&gt;Host&lt;/code&gt; block, which avoids the extra authentication attempts. If you keep separate keys for different projects or separate personal and work use, this option is effectively mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping shared settings across similar servers with wildcards
&lt;/h2&gt;

&lt;p&gt;When you have several servers with similar setups, &lt;code&gt;Host&lt;/code&gt; supports wildcard patterns so you can group shared settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; staging-*
    &lt;span class="k"&gt;User&lt;/span&gt; deploy
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
    &lt;span class="k"&gt;ServerAliveInterval&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

&lt;span class="k"&gt;Host&lt;/span&gt; staging-web1
    &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.0.113.10
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/staging_key

&lt;span class="k"&gt;Host&lt;/span&gt; staging-web2
    &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.0.113.20
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/staging_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenSSH reads the config file top to bottom and &lt;strong&gt;keeps the first value it finds for a given option&lt;/strong&gt; — later matching blocks don't override earlier ones. That means specific &lt;code&gt;Host&lt;/code&gt; blocks need to come &lt;strong&gt;before&lt;/strong&gt; the wildcard block. If &lt;code&gt;staging-*&lt;/code&gt; were placed first, the individual blocks' &lt;code&gt;HostName&lt;/code&gt; values would never be read, and every alias would try to connect to the same server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ServerAliveInterval 30&lt;/code&gt; sends a keepalive packet every 30 seconds, which matters on networks that drop idle connections (some routers and firewalls do this). It's particularly useful when running a long backup job over SSH that you don't want interrupted mid-way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting the config file with &lt;code&gt;Include&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;As the number of projects grows, cramming every server into a single &lt;code&gt;~/.ssh/config&lt;/code&gt; gets hard to navigate. OpenSSH 7.3 and later supports an &lt;code&gt;Include&lt;/code&gt; directive to split the file up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ~/.ssh/config&lt;/span&gt;
&lt;span class="k"&gt;Include&lt;/span&gt; ~/.ssh/config.d/*.conf

&lt;span class="k"&gt;Host&lt;/span&gt; *
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping one file per project or client under &lt;code&gt;~/.ssh/config.d/&lt;/code&gt; means that when a project ends, you delete one file and the cleanup is done. &lt;code&gt;Include&lt;/code&gt; expands &lt;strong&gt;exactly where it's written&lt;/strong&gt;, so whether you place it before or after a catch-all &lt;code&gt;Host *&lt;/code&gt; block changes which settings take precedence — worth keeping in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this differs from WP-CLI aliases
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wpmm.jp/blog/wp-cli-alias-multi-site-management/" rel="noopener noreferrer"&gt;Using WP-CLI aliases to switch between multiple WordPress environments safely&lt;/a&gt; covered &lt;code&gt;wp-cli.yml&lt;/code&gt; aliases, which manage "which WordPress site a command runs against" at the application layer — inside WP-CLI itself. &lt;code&gt;~/.ssh/config&lt;/code&gt; operates one layer down, at the OS's SSH client, managing "how to connect to which server" in the first place.&lt;/p&gt;

&lt;p&gt;The two are independent, but combining them compounds the benefit. &lt;code&gt;~/.ssh/config&lt;/code&gt; organizes the connection itself; WP-CLI aliases then organize the sites within each server. Together, both the connection command and the site-targeting command stay short and less error-prone.&lt;/p&gt;

&lt;h2&gt;
  
  
  A security note
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;~/.ssh/config&lt;/code&gt; stores hostnames, IP addresses, and port numbers in plain text. As a baseline, keep the file's permissions at &lt;code&gt;600&lt;/code&gt; (readable/writable only by you) — some OpenSSH client implementations will warn if permissions are looser than that.&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="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's also worth being deliberate about not committing this file to a Git repository by accident, since that would leak internal connection details externally. &lt;code&gt;~/.ssh/&lt;/code&gt; is typically outside any project repo, but if you version-control your dotfiles, make sure it's explicitly excluded via &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Alias name (freely chosen)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HostName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Actual connection target (IP or domain)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IdentityFile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Path to the private key to use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Offers only the specified key, avoiding extra auth attempts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Host pattern*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies shared settings across multiple servers via wildcard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Include&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Splits the config file by project or client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For anyone maintaining multiple WordPress environments over SSH, &lt;code&gt;~/.ssh/config&lt;/code&gt; is a setup cost paid once — after that, all you need to remember is an alias name. The real payoff isn't the shorter commands; it's that the &lt;strong&gt;structural risk of connecting to the wrong server drops significantly&lt;/strong&gt;. For choosing which key type to generate in the first place, see also &lt;a href="https://en.wpmm.jp/blog/ssh-key-types-rsa-ed25519-explained/" rel="noopener noreferrer"&gt;SSH key types (RSA / ED25519 / ECDSA) — what actually differs, and which one to pick&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>SSH key types (RSA / ED25519 / ECDSA) — what actually differs, and which one to pick</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 03 Sep 2026 02:55:19 +0000</pubDate>
      <link>https://dev.to/susumun/ssh-key-types-rsa-ed25519-ecdsa-what-actually-differs-and-which-one-to-pick-m26</link>
      <guid>https://dev.to/susumun/ssh-key-types-rsa-ed25519-ecdsa-what-actually-differs-and-which-one-to-pick-m26</guid>
      <description>&lt;p&gt;If you maintain WordPress sites over SSH — running &lt;code&gt;wp-cli&lt;/code&gt; remotely, checking logs, transferring files with &lt;code&gt;rsync&lt;/code&gt; — you're relying on SSH key authentication as the foundation. What rarely gets explained clearly is what you're actually choosing when &lt;code&gt;ssh-keygen -t&lt;/code&gt; asks for an algorithm. This post walks through what RSA, ECDSA, and ED25519 actually rest on mathematically, and which one makes sense to pick today.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: SSH key authentication uses public-key cryptography. You keep a &lt;strong&gt;private key&lt;/strong&gt; on your machine (never shared) and place a &lt;strong&gt;public key&lt;/strong&gt; on the server (safe to share). The server issues a challenge that only the matching private key can answer, so you prove who you are without ever sending a password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What actually differs between the three types
&lt;/h2&gt;

&lt;p&gt;The main options you'll see with &lt;code&gt;ssh-keygen -t &amp;lt;algorithm&amp;gt;&lt;/code&gt; are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Mathematical basis&lt;/th&gt;
&lt;th&gt;Typical key size&lt;/th&gt;
&lt;th&gt;Introduced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Difficulty of factoring large numbers&lt;/td&gt;
&lt;td&gt;2048–4096 bits&lt;/td&gt;
&lt;td&gt;Late 1990s (for SSH use)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ECDSA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Discrete logarithm problem over elliptic curves&lt;/td&gt;
&lt;td&gt;256–521 bits&lt;/td&gt;
&lt;td&gt;Late 2000s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ED25519&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Elliptic-curve cryptography (Ed25519's own Twisted Edwards curve)&lt;/td&gt;
&lt;td&gt;Fixed, ~256-bit equivalent&lt;/td&gt;
&lt;td&gt;Published 2011, supported since OpenSSH 6.5 (2014)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three share the same core property — you can't forge a signature without the private key — but they differ in &lt;strong&gt;what mathematical hardness assumption they rely on&lt;/strong&gt; and &lt;strong&gt;how the keys are generated&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Even though ECDSA and ED25519 are both "elliptic-curve" algorithms, they aren't interchangeable. ECDSA uses curve parameters standardized by NIST. ED25519 was published with parameters designed independently of any single government standards body. This distinction matters less as a practical threat and more as a design philosophy — it's part of why ED25519 tends to be preferred in contexts where provenance of the parameters is a concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key size and the speed difference you'll actually notice
&lt;/h2&gt;

&lt;p&gt;To reach a comparable security strength (roughly, how much computation a brute-force attack would require), the required key length varies a lot by algorithm. A commonly recommended 2048-bit RSA key is considered roughly comparable in strength to a 256-bit ED25519 key. Don't assume a longer bit count automatically means "more secure" — different math means different bit counts are needed for the same effective strength.&lt;/p&gt;

&lt;p&gt;The difference you'll actually feel day to day is in &lt;strong&gt;how fast key generation, signing, and verification run&lt;/strong&gt;. Because ED25519 keys are shorter, the cryptographic operations behind them are faster — &lt;code&gt;ssh-keygen&lt;/code&gt; finishes quicker, and every login carries a lighter computational load. A large RSA key (4096 bits, say) does raise the security margin, but at the cost of heavier computation on every operation. If you connect over SSH frequently as part of routine maintenance, that difference adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you actually use
&lt;/h2&gt;

&lt;p&gt;Short answer: for a newly generated key today, &lt;strong&gt;ED25519 is the reasonable default&lt;/strong&gt;, for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Comparable strength at a much shorter key length&lt;/strong&gt; — as noted, its ~256-bit key is roughly on par with a 2048–3072-bit RSA key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster in practice&lt;/strong&gt; — generation, signing, and verification are all lighter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less room for implementation mistakes&lt;/strong&gt; — RSA implementations have historically had a string of vulnerabilities tied to poor randomness during key generation; ED25519's design leaves much less room for that class of implementation-specific bug&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That said, there are real situations where ED25519 isn't the right — or even available — choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Older servers or embedded network devices&lt;/strong&gt;: environments running an OpenSSH version older than 6.5 (2014), or embedded SSH implementations with limited algorithm support, may not support ED25519 at all. RSA (2048 bits minimum, ideally 3072–4096) is the fallback there&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance or internal policy requirements&lt;/strong&gt;: if an audit or internal policy mandates a specific algorithm, follow that requirement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ECDSA&lt;/strong&gt;: given the NIST-parameter provenance question mentioned above, it's rarely the algorithm you'd actively choose for a brand-new key. There's no urgent need to replace ECDSA keys already in use, but it's a weak default for new ones&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Generating a key (general steps)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ED25519 (recommended) — the comment helps you identify the key later&lt;/span&gt;
ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"example-comment"&lt;/span&gt;

&lt;span class="c"&gt;# RSA, when you need broader compatibility (3072 bits or higher recommended)&lt;/span&gt;
ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"example-comment"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key generation prompts for a passphrase. That passphrase encrypts the private key file itself, so even if the file is stolen, it can't be used immediately. Leaving it blank means the security of your account rests entirely on the private key file never leaking — setting a passphrase is worth doing whenever practical.&lt;/p&gt;

&lt;p&gt;The public key (the &lt;code&gt;.pub&lt;/code&gt; file) gets appended to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on the server side. The private key should never leave the machine it was generated on. When multiple people share access to a server, the right pattern is for each person to generate their own key pair and register only their public key — not to hand around a shared private key file.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommended type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generating a new key (compatible environment)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ED25519&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environments older than OpenSSH 6.5, embedded devices&lt;/td&gt;
&lt;td&gt;RSA (3072–4096 bits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy or audit mandates a specific algorithm&lt;/td&gt;
&lt;td&gt;Whatever's mandated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing ECDSA keys already deployed&lt;/td&gt;
&lt;td&gt;No urgent need to replace, but avoid for new keys&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Choosing an SSH key type isn't something you revisit often once decided, but understanding the reasoning behind it makes it easier to separate "older documentation defaulted to RSA because that's what was standard at the time" from "ED25519 is now the sensible default." For managing SSH access across multiple WordPress environments safely, see also &lt;a href="https://en.wpmm.jp/blog/wp-cli-alias-multi-site-management/" rel="noopener noreferrer"&gt;using WP-CLI aliases to switch between multiple environments&lt;/a&gt;. Once your key is set up, organizing where you connect is covered in &lt;a href="https://en.wpmm.jp/blog/ssh-config-file-host-aliases/" rel="noopener noreferrer"&gt;organizing per-host settings with ~/.ssh/config&lt;/a&gt;. For the layer beneath this one — why use a key instead of a password in the first place — see &lt;a href="https://en.wpmm.jp/blog/ssh-key-auth-vs-password-authentication/" rel="noopener noreferrer"&gt;why SSH key authentication beats password authentication&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How the WordPress transient API works, and when `wp transient delete` actually helps</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 02 Sep 2026 00:42:20 +0000</pubDate>
      <link>https://dev.to/susumun/how-the-wordpress-transient-api-works-and-when-wp-transient-delete-actually-helps-17po</link>
      <guid>https://dev.to/susumun/how-the-wordpress-transient-api-works-and-when-wp-transient-delete-actually-helps-17po</guid>
      <description>&lt;p&gt;WordPress ships with a built-in way to store data temporarily — save something for a fixed window of time, and it stops being valid once that window closes. This is the transient API, and both WordPress core and countless plugins lean on it to cache things like external API responses or the results of expensive calculations. It's a genuinely useful mechanism, but used without understanding how it actually behaves, expired entries can pile up and quietly bloat the database.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: the transient API is WordPress core's name for a small set of PHP functions — &lt;code&gt;set_transient()&lt;/code&gt;, &lt;code&gt;get_transient()&lt;/code&gt;, &lt;code&gt;delete_transient()&lt;/code&gt; — built around the idea of a cache entry with an expiration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How a transient actually works
&lt;/h2&gt;

&lt;p&gt;Saving a transient means specifying three things: a value, a key, and an expiration in seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;set_transient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'weather_data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$api_response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// cache for one hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where that value actually gets stored depends on the site's setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default setup&lt;/strong&gt; (most shared hosting environments): it lands in the &lt;code&gt;wp_options&lt;/code&gt; table as a row named &lt;code&gt;_transient_&amp;lt;key&amp;gt;&lt;/code&gt;, with a matching &lt;code&gt;_transient_timeout_&amp;lt;key&amp;gt;&lt;/code&gt; row holding the expiration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With a persistent object cache&lt;/strong&gt; (a plugin backed by Redis or Memcached): the value goes to that cache layer instead of &lt;code&gt;wp_options&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When &lt;code&gt;get_transient()&lt;/code&gt; is called, WordPress compares the timeout value against the current time and returns &lt;code&gt;false&lt;/code&gt; if the entry has expired. At that point, the design intends for the stale row to be cleaned up automatically — but that cleanup isn't as reliable as it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why expired entries stick around
&lt;/h2&gt;

&lt;p&gt;In theory, an expired transient should disappear. In practice, &lt;code&gt;wp_options&lt;/code&gt; can accumulate a large number of long-expired rows. Two things typically cause this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_transient()&lt;/code&gt; is never called again for that key.&lt;/strong&gt; The automatic cleanup described above is passive — it only fires when something actually tries to read the value and finds it expired. It isn't an active sweep. If a plugin sets a value once and never checks it again, the row just sits there past its expiration indefinitely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP-Cron's garbage collection doesn't run.&lt;/strong&gt; WordPress core schedules a periodic cleanup job (&lt;code&gt;wp_scheduled_delete&lt;/code&gt;, among others) to purge expired data. But &lt;a href="https://en.wpmm.jp/blog/wp-cli-cron-inspect-and-run/" rel="noopener noreferrer"&gt;WP-Cron is a pseudo-cron that only fires on page visits&lt;/a&gt;, so on a site with very light traffic, that cleanup can go a long time without actually executing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What makes this worse is that most &lt;code&gt;_transient_&lt;/code&gt;-prefixed options are saved with &lt;strong&gt;autoload set to yes&lt;/strong&gt; — meaning WordPress loads them into memory on every single page request, whether or not that page needs them. A handful of stale rows is harmless. Thousands of them, and every page load starts carrying that weight, even pages that never touch the cached values in question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspecting and cleaning up with &lt;code&gt;wp transient&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;WP-CLI ships a dedicated set of subcommands for this:&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;# List everything currently stored as a transient&lt;/span&gt;
wp transient list

&lt;span class="c"&gt;# Delete one specific key&lt;/span&gt;
wp transient delete weather_data

&lt;span class="c"&gt;# Delete only the ones that have already expired&lt;/span&gt;
wp transient delete &lt;span class="nt"&gt;--expired&lt;/span&gt;

&lt;span class="c"&gt;# Delete everything, expired or not&lt;/span&gt;
wp transient delete &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction between &lt;code&gt;--expired&lt;/code&gt; and &lt;code&gt;--all&lt;/code&gt; matters. &lt;code&gt;--expired&lt;/code&gt; only touches rows that are already past their timeout, so running it has essentially no side effects. &lt;code&gt;--all&lt;/code&gt; wipes out live, still-valid cache entries too — which means plugins have to recompute or re-fetch that data immediately afterward, possibly hitting external APIs all at once and causing a brief slowdown right after you run it. Unless you're deliberately forcing a full cache reset while troubleshooting something specific, &lt;code&gt;--expired&lt;/code&gt; is the safer default for routine maintenance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: on a site running a persistent object cache (Redis or Memcached), the &lt;code&gt;wp transient&lt;/code&gt; commands operate against that cache layer instead of &lt;code&gt;wp_options&lt;/code&gt;. Checking &lt;code&gt;wp_options&lt;/code&gt; directly — for example with &lt;code&gt;wp db query "SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '_transient_%'"&lt;/code&gt; — only reflects reality on sites without a persistent object cache in front of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When it's worth running
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;As a first check when a site feels generally slow.&lt;/strong&gt; Run &lt;code&gt;wp option list --autoload=on --search='_transient_%'&lt;/code&gt; to see how many autoloaded transient rows exist. Thousands of rows is a reasonable signal that &lt;code&gt;wp transient delete --expired&lt;/code&gt; is worth trying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After swapping out or bulk-removing plugins.&lt;/strong&gt; Some plugins don't bother calling &lt;code&gt;delete_transient()&lt;/code&gt; for their own keys before being uninstalled, leaving orphaned &lt;code&gt;_transient_&lt;/code&gt; rows with no code left to ever read or clean them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As part of routine maintenance.&lt;/strong&gt; Folding transient cleanup into the same cycle as &lt;code&gt;wp db check&lt;/code&gt; / &lt;code&gt;wp db optimize&lt;/code&gt; turns autoload bloat into something checked on a predictable schedule, rather than something that only gets noticed once it's already affecting page load times.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want to do&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List everything currently cached as a transient&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp transient list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete one specific key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp transient delete &amp;lt;key&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete only expired entries (safe)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp transient delete --expired&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete everything, including live cache (has side effects — use for troubleshooting)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp transient delete --all&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check for autoload bloat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp option list --autoload=on --search='_transient_%'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The transient API's whole premise — save it temporarily, let it expire on its own — only holds up when &lt;code&gt;get_transient()&lt;/code&gt; gets called regularly and WP-Cron's garbage collection is actually running. Folding a transient audit into the same routine as &lt;a href="https://en.wpmm.jp/blog/wp-db-check-optimize-database-health/" rel="noopener noreferrer"&gt;checking database health with &lt;code&gt;wp db check&lt;/code&gt; / &lt;code&gt;wp db optimize&lt;/code&gt;&lt;/a&gt; is a straightforward way to catch autoload bloat — one of the harder-to-see kinds of database debt — before it accumulates into a real performance problem.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>`wp db check` / `wp db optimize` — the database health commands that get overlooked</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 01 Sep 2026 00:21:39 +0000</pubDate>
      <link>https://dev.to/susumun/wp-db-check-wp-db-optimize-the-database-health-commands-that-get-overlooked-m9f</link>
      <guid>https://dev.to/susumun/wp-db-check-wp-db-optimize-the-database-health-commands-that-get-overlooked-m9f</guid>
      <description>&lt;p&gt;A WordPress database doesn't tidy itself up over time. Spam comments pile up, expired transients linger, post revisions accumulate, and tables left behind by uninstalled plugins never quite go away. All of that adds up to bloated tables, and occasionally to actual table corruption. This is territory the admin dashboard barely shows you — but WP-CLI reaches it directly with two short commands: &lt;code&gt;wp db check&lt;/code&gt; and &lt;code&gt;wp db optimize&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: WP-CLI's &lt;code&gt;wp db&lt;/code&gt; subcommands operate directly on the MySQL (or MariaDB) database WordPress uses, without going through the admin dashboard. Connection details are read automatically from &lt;code&gt;wp-config.php&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;wp db check&lt;/code&gt; — verifying table health
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp db check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, this runs the equivalent of &lt;code&gt;mysqlcheck --check&lt;/code&gt; against every table and reports each one's status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wp_posts                       OK
wp_options                      OK
wp_postmeta                     OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a table comes back &lt;code&gt;corrupt&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;INSERT&lt;/code&gt; queries against it start failing. That can surface as something oddly specific — a single page going blank, one particular post refusing to save — with no obvious connection to a database problem. Running &lt;code&gt;wp db check&lt;/code&gt; on a regular schedule catches that kind of issue before it turns into a visible symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;wp db optimize&lt;/code&gt; — defragmenting tables
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp db optimize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one runs the equivalent of &lt;code&gt;mysqlcheck --optimize&lt;/code&gt;, applying &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; to each table. Tables that see a lot of row deletions and updates tend to become fragmented on disk over time. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; rebuilds the table and reclaims the space that deleted rows used to occupy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: behavior differs by storage engine. WordPress's default engine, &lt;strong&gt;InnoDB&lt;/strong&gt;, handles &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; internally as a table rebuild (roughly equivalent to &lt;code&gt;ALTER TABLE ... FORCE&lt;/code&gt;), which both defragments the table and refreshes its statistics. The older &lt;strong&gt;MyISAM&lt;/strong&gt; engine doesn't reclaim space from deleted rows automatically at all — that disk space only gets released once &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; runs. Some installs set up through a hosting provider's one-click installer still carry MyISAM tables left over from an older default, so it's worth checking for a mixed-engine setup once with &lt;code&gt;wp db query "SHOW TABLE STATUS"&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;wp db optimize&lt;/code&gt; locks each table for writes while it runs. On a large table that can mean a brief slowdown for site visitors, so it's safer to run during a low-traffic window rather than in the middle of the day.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to actually run these
&lt;/h2&gt;

&lt;p&gt;Neither command causes harm if run at an arbitrary time, but a few moments make the payoff clearer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Right after a bulk deletion&lt;/strong&gt; — clearing out spam comments in bulk, deleting a large batch of posts, or uninstalling a plugin that no longer gets used, all shrink row counts suddenly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before a major update&lt;/strong&gt; — running &lt;code&gt;wp db check&lt;/code&gt; ahead of a large WordPress core or plugin update makes it easier to tell, after the fact, whether a problem was caused by the update or was already sitting in the database beforehand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As part of routine maintenance&lt;/strong&gt; — folding these into a weekly or monthly cycle turns fragmentation and corruption from "discovered too late" into something checked on a predictable schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If corruption turns up — &lt;code&gt;wp db repair&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;wp db check&lt;/code&gt; reports a table as &lt;code&gt;corrupt&lt;/code&gt;, WP-CLI offers &lt;code&gt;wp db repair&lt;/code&gt; (the equivalent of &lt;code&gt;mysqlcheck --repair&lt;/code&gt;) as a next step. Repair isn't guaranteed to restore the data perfectly — depending on how severe the corruption is, some data can still be lost. &lt;strong&gt;Taking a fresh backup before attempting a repair is a prerequisite, not an optional step.&lt;/strong&gt; A backup taken moments before the corruption is worth far more than one taken after the fact.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want to do&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check every table's health&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp db check&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defragment tables and reclaim disk space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp db optimize&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attempt to repair a corrupted table&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wp db repair&lt;/code&gt; (back up first)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check for mixed storage engines&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp db query "SHOW TABLE STATUS"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both &lt;code&gt;wp db check&lt;/code&gt; and &lt;code&gt;wp db optimize&lt;/code&gt; are lightweight — they finish in seconds to a few minutes — yet they leave a record of the state of the one part of a WordPress site that's hardest to see from the dashboard. That's exactly why it's worth checking mechanically and on a schedule, rather than relying on symptoms to surface first.&lt;/p&gt;

&lt;p&gt;WP-CLI's ability to reach the database directly, without going through the admin dashboard, is the same idea behind &lt;a href="https://en.wpmm.jp/blog/wp-search-replace-serialized-safe/" rel="noopener noreferrer"&gt;safely rewriting serialized data with &lt;code&gt;wp search-replace&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://en.wpmm.jp/blog/wp-cli-lockout-recovery/" rel="noopener noreferrer"&gt;recovering from a wp-admin lockout&lt;/a&gt;. Checking database health is one more basic operation built on that same foundation.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Using WP-CLI aliases to switch between multiple WordPress environments safely</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 31 Aug 2026 00:19:21 +0000</pubDate>
      <link>https://dev.to/susumun/using-wp-cli-aliases-to-switch-between-multiple-wordpress-environments-safely-34a7</link>
      <guid>https://dev.to/susumun/using-wp-cli-aliases-to-switch-between-multiple-wordpress-environments-safely-34a7</guid>
      <description>&lt;p&gt;Anyone managing several WordPress environments — production, staging, or separate installs for different languages — ends up re-typing SSH connection details and install paths every time they run a command. Building that connection string by hand each time invites mistakes: a copy-paste error, or reusing a stale path, can send a command to the wrong environment entirely. That risk matters most for write commands — bulk plugin updates or database operations — where hitting the wrong target has real consequences.&lt;/p&gt;

&lt;p&gt;WP-CLI has a built-in feature for exactly this problem: aliases.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: A WP-CLI "alias" assigns a short name (like &lt;code&gt;@production&lt;/code&gt;) to a set of connection details — an SSH target and a WordPress install path. Once registered, that short name replaces the full connection string on every subsequent command.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where aliases live
&lt;/h2&gt;

&lt;p&gt;Aliases are registered in one of two config files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project-level&lt;/strong&gt;: &lt;code&gt;wp-cli.yml&lt;/code&gt; in the working directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global&lt;/strong&gt;: &lt;code&gt;~/.wp-cli/config.yml&lt;/code&gt; in the home directory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the same alias name exists in both, the project-level file takes precedence. If the same environments get used across multiple projects, consolidating aliases in &lt;code&gt;~/.wp-cli/config.yml&lt;/code&gt; keeps things easier to manage.&lt;/p&gt;

&lt;p&gt;Example registration (&lt;code&gt;config.yml&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;@production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssh&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user@production.example.com:22&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/www/production/wordpress&lt;/span&gt;

&lt;span class="na"&gt;@staging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssh&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user@staging.example.com:22&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/www/staging/wordpress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh&lt;/code&gt; specifies the username, host, and port; &lt;code&gt;path&lt;/code&gt; points to the WordPress install directory. This assumes key-based SSH authentication — passwords should never be written into &lt;code&gt;config.yml&lt;/code&gt;. If this file is tracked in a repository, an accidental commit turns it into a leak vector for connection details, so keep it in &lt;code&gt;.gitignore&lt;/code&gt;, or store it outside the repository in the home directory instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling a single alias
&lt;/h2&gt;

&lt;p&gt;Once registered, commands that previously required an &lt;code&gt;ssh&lt;/code&gt; login first can run in a single line from your local machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp @production plugin list
wp @staging plugin list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, WP-CLI reads the alias's connection details and runs the command against the remote WordPress install over SSH, streaming the result back to the local terminal exactly as if it had run locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the same command across multiple environments with group aliases
&lt;/h2&gt;

&lt;p&gt;Individual aliases can also be bundled into a "group alias":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;@all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="s"&gt;staging&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;wp @all plugin list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running a group alias makes WP-CLI execute the command against every listed alias in turn, labeling each block of output with which alias it came from. There's no need to retype the same command per environment, and the labeled output makes it easy to trace which result belongs to which site — a form of record-keeping that matters when reviewing what ran where.&lt;/p&gt;

&lt;h2&gt;
  
  
  Points to watch when using aliases
&lt;/h2&gt;

&lt;p&gt;Aliases trade convenience for a wider blast radius when something is misconfigured. A few things worth keeping in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name aliases so the environment is obvious at a glance.&lt;/strong&gt; Short forms like &lt;code&gt;@a&lt;/code&gt; or &lt;code&gt;@b&lt;/code&gt; become ambiguous the moment you're not looking at the config file — &lt;code&gt;@production&lt;/code&gt; and &lt;code&gt;@staging&lt;/code&gt; remove that ambiguity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get in the habit of running &lt;code&gt;wp cli alias list&lt;/code&gt; before executing anything unfamiliar.&lt;/strong&gt; It shows the currently registered aliases and their targets without needing to open the config file directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid running write commands through a group alias.&lt;/strong&gt; Something like &lt;code&gt;wp plugin update --all&lt;/code&gt; or a &lt;code&gt;wp db&lt;/code&gt; command run against &lt;code&gt;@all&lt;/code&gt; can't be stopped partway if the first environment produces an unexpected result. Read-only commands (&lt;code&gt;plugin list&lt;/code&gt;, checking &lt;code&gt;core version&lt;/code&gt;, and similar) are a good fit for group execution when you just need a status overview; commands that change state are safer run one alias at a time, with a chance to confirm each result before moving to the next&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider separating SSH keys by environment.&lt;/strong&gt; Reusing one key across multiple production environments means a single key compromise affects all of them. Depending on how critical each environment is, splitting keys is worth considering&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want to do&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List registered aliases&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp cli alias list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a command against a single alias&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp @production plugin list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a command across all environments at once&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp @all plugin list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run one environment at a time (safer for write commands)&lt;/td&gt;
&lt;td&gt;Execute manually per alias&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When working across multiple WordPress environments, registering aliases once tends to prevent more mistakes than building connection strings by hand each time. Group alias output labels which environment produced which result, which makes it well suited to read-only status checks. Bulk-running write commands across environments deserves more caution — in situations where accuracy and traceability matter more than speed, running one environment at a time on purpose is a reasonable choice to keep.&lt;/p&gt;

&lt;p&gt;WP-CLI's ability to reach into WordPress without going through the admin dashboard shows up elsewhere in this series too: &lt;a href="https://en.wpmm.jp/blog/wp-cli-cron-inspect-and-run/" rel="noopener noreferrer"&gt;inspecting what's actually scheduled in WP-Cron&lt;/a&gt; and &lt;a href="https://en.wpmm.jp/blog/wp-cli-lockout-recovery/" rel="noopener noreferrer"&gt;recovering from a wp-admin lockout&lt;/a&gt;. Aliases extend that same idea to the problem of managing connection details across several environments at once.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Comparing pricing models across four WordPress maintenance tools — what the shape of a price reveals about cost structure</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 28 Aug 2026 23:27:22 +0000</pubDate>
      <link>https://dev.to/susumun/comparing-pricing-models-across-four-wordpress-maintenance-tools-what-the-shape-of-a-price-4711</link>
      <guid>https://dev.to/susumun/comparing-pricing-models-across-four-wordpress-maintenance-tools-what-the-shape-of-a-price-4711</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;While surveying the major WordPress maintenance tools (ManageWP, MainWP, WP Umbrella, and InfiniteWP), one thing stood out once their pricing was laid side by side: this isn't just a matter of "cheaper vs. more expensive." &lt;strong&gt;The shape of the pricing itself differs completely from company to company.&lt;/strong&gt; And that shape isn't arbitrary — it closely mirrors how each company actually delivers its service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four companies, four pricing shapes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ManageWP: free core, stacked per-site add-ons&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core dashboard is free. Features like Backup, Security, Reports, and White-Label are sold separately as per-site monthly add-ons, typically $1–$2 each. This gives flexibility to pay only for what you need, but assembling the full feature set means costs stack up as (number of sites) × (number of add-ons). Starting free and paying only for the pieces you want is a classic pattern for a SaaS built from independently billed feature modules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MainWP: free core (self-hosted) plus a flat Pro extension fee&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;MainWP's dashboard itself is open source and free. The practical features (backup extension, reporting extension, etc.) come as Pro Extensions, priced at $29/month, $199/year, or a one-time $599. What's easy to overlook here: &lt;strong&gt;you must provide, host, and maintain the WordPress site that runs this dashboard yourself.&lt;/strong&gt; The software itself is free, but the infrastructure cost and upkeep of running it fall on you separately. It's a pricing model that closely inherits the culture of open-source, self-hosted software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WP Umbrella: a single flat per-site rate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;€1.99 per site per month, a clear and uniform rate regardless of scale. Because it's cloud-based and runs independent uptime monitoring, PHP error monitoring, and PageSpeed checks for every site, its own infrastructure cost genuinely scales roughly linearly with the number of sites monitored. &lt;strong&gt;Because the company's own cost structure scales linearly, billing customers at a single flat per-site rate lines up naturally with that cost.&lt;/strong&gt; It's a case where the cost structure and the pricing model match cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;InfiniteWP: free core plus an annual add-on license&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core is free and supports unlimited sites, but the practical agency-grade features (backup, white-label, scheduling, uptime monitoring) come as an add-on, sold as an annual license starting at $147/year. A hosted version (a bundle covering 100 sites) is also available at $597/year. Paying the same amount again every year to renew is a structure that reflects &lt;strong&gt;a software-licensing tradition inherited from the on-premise era&lt;/strong&gt;, more than a continuously metered cloud service. Cost doesn't fluctuate finely with site count — it's bundled by license unit instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of a price is a mirror of cost structure
&lt;/h2&gt;

&lt;p&gt;Laying all four side by side, the differences in pricing model turn out not to be arbitrary marketing choices — they reflect &lt;strong&gt;how each company actually delivers its service&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If cloud infrastructure monitors each site independently, cost scales with the number of sites — so per-site metered billing is the natural fit (WP Umbrella).&lt;/li&gt;
&lt;li&gt;If the customer runs their own dashboard, the company's own infrastructure cost is effectively passed on to the customer — so a flat, software-license-style fee makes sense (MainWP).&lt;/li&gt;
&lt;li&gt;If each feature is delivered as its own independent service (backup storage, an uptime-monitoring server, etc.), per-feature metered billing arises naturally (ManageWP).&lt;/li&gt;
&lt;li&gt;An annual bundled-renewal model reflects more of a "right to use a software asset" mindset than an ongoing cloud operating cost (InfiniteWP).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: SaaS (Software as a Service) is a delivery model where software is used over the internet rather than installed locally, typically billed monthly or annually as a subscription. What varies between providers is the &lt;strong&gt;unit&lt;/strong&gt; of that billing — per site, per feature, or per license — and that unit tends to follow directly from the provider's own infrastructure structure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to watch for when comparing quotes
&lt;/h2&gt;

&lt;p&gt;Understanding these differences in pricing shape makes it easier to spot costs that a simple "how much per month" comparison hides. MainWP's Pro Extension price alone looks inexpensive, but the hosting cost and upkeep of the WordPress site running the dashboard are separate and easy to miss. ManageWP lets you start free, but assembling the full feature set means paying for every add-on you need, one by one.&lt;/p&gt;

&lt;p&gt;Looking at what cost structure a pricing model actually assumes — rather than just the headline number — gives a clearer picture of the real total cost of running the tool, and whether it fits how you actually work.&lt;/p&gt;

&lt;p&gt;Differences in connection architecture and rollback design, beyond pricing, are covered in two earlier pieces: "&lt;a href="https://en.wpmm.jp/blog/wordpress-maintenance-connection-architectures/" rel="noopener noreferrer"&gt;Connection architectures for WordPress maintenance tools — mapping four products on a two-axis grid&lt;/a&gt;" and "&lt;a href="https://en.wpmm.jp/blog/wordpress-maintenance-industry-gaps/" rel="noopener noreferrer"&gt;Three gaps the WordPress maintenance industry still hasn't solved — from a survey of four major tools&lt;/a&gt;." Read together, they give a fuller picture of how differently this industry's tools are designed.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A tabbed form that silently refused to submit — required fields hidden behind another tab</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 28 Aug 2026 00:21:37 +0000</pubDate>
      <link>https://dev.to/susumun/a-tabbed-form-that-silently-refused-to-submit-required-fields-hidden-behind-another-tab-52e4</link>
      <guid>https://dev.to/susumun/a-tabbed-form-that-silently-refused-to-submit-required-fields-hidden-behind-another-tab-52e4</guid>
      <description>&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;The site edit modal kept accumulating fields — site name, category, SSH connection details, WordPress install location — until editing anything meant scrolling up and down a single long form to find the right field. To clean this up, we split it into three tabs: "Registration info," "SSH," and "WordPress info." That change broke form submission itself, in a way that was hard to spot at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tabbing broke
&lt;/h2&gt;

&lt;p&gt;The tab implementation itself is straightforward. Each tab's fields live in a &lt;code&gt;&amp;lt;div class="site-tab-content" data-tab="..."&amp;gt;&lt;/code&gt;, and CSS toggles which one is visible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.site-tab-content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.site-tab-content.active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&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;An inactive tab is hidden with &lt;code&gt;display: none&lt;/code&gt;. Nothing unusual so far, and visually it worked fine.&lt;/p&gt;

&lt;p&gt;The problem showed up when a &lt;code&gt;required&lt;/code&gt; field sat in a tab that was not currently active, and the user left it empty while saving from a different tab. Clicking the save button did &lt;strong&gt;nothing&lt;/strong&gt;. No error message appeared. The form just looked stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: a browser cannot report an error on a field it cannot show
&lt;/h2&gt;

&lt;p&gt;HTML5 form validation works by having the browser automatically block the &lt;code&gt;submit&lt;/code&gt; event whenever a constrained field (like &lt;code&gt;required&lt;/code&gt;) fails, then focusing that field and showing its standard validation bubble (equivalent to calling &lt;code&gt;reportValidity()&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;reportValidity()&lt;/code&gt; is a method from the HTML5 Constraint Validation API. It checks whether a form element's value satisfies its constraints (required, pattern, etc.) and, if not, displays the browser's standard error bubble.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But when the failing field sits inside a tab hidden with &lt;code&gt;display: none&lt;/code&gt;, the browser has nowhere to anchor that error bubble. It still faithfully blocks the submit — but it cannot visualize the error, so it simply stops without any visible feedback. From the user's side, this looks exactly like a button that does not respond.&lt;/p&gt;

&lt;p&gt;Before tabbing, every field lived on the same screen, so this never surfaced. Introducing tabs — a UI pattern that deliberately limits what's visible at once — broke an implicit assumption the browser's built-in validation depends on: that an invalid field is always visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: novalidate plus JS-driven validation
&lt;/h2&gt;

&lt;p&gt;The fix was to stop relying on the browser's automatic blocking. Adding &lt;code&gt;novalidate&lt;/code&gt; to the form disables that automatic block and guarantees the &lt;code&gt;submit&lt;/code&gt; event always fires, letting JavaScript take full control of validation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveSite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// If checkValidity() fails, switch to the tab containing the first&lt;/span&gt;
    &lt;span class="c1"&gt;// :invalid element, then focus it and call reportValidity().&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkValidity&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;firstInvalid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firstInvalid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;firstInvalid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.site-tab-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabContent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tabContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;switchSiteTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="c1"&gt;// Focus/scroll may not work right after a tab switch,&lt;/span&gt;
            &lt;span class="c1"&gt;// so call reportValidity() on the next frame.&lt;/span&gt;
            &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nx"&gt;firstInvalid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preventScroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
                &lt;span class="nx"&gt;firstInvalid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reportValidity&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;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// abort submission on validation failure&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...normal save flow continues here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;novalidate&lt;/code&gt; does not disable the constraints themselves.&lt;/strong&gt; Attributes like &lt;code&gt;required&lt;/code&gt; and &lt;code&gt;pattern&lt;/code&gt; remain in effect, and both &lt;code&gt;checkValidity()&lt;/code&gt; and the &lt;code&gt;:invalid&lt;/code&gt; pseudo-class still work as expected. What &lt;code&gt;novalidate&lt;/code&gt; disables is only the browser's automatic "block submit and show the error" behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The code finds the first &lt;code&gt;:invalid&lt;/code&gt; element and forces a switch to its tab before calling &lt;code&gt;reportValidity()&lt;/code&gt;.&lt;/strong&gt; By making the field visible first, the browser can render its error bubble without issue. Since a tab switch may not have finished painting yet, the code waits one frame via &lt;code&gt;requestAnimationFrame&lt;/code&gt; before calling &lt;code&gt;focus()&lt;/code&gt; and &lt;code&gt;reportValidity()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A concrete case: making the SSH profile conditionally required
&lt;/h2&gt;

&lt;p&gt;This mechanism paid off directly with the SSH profile requirement. The site edit modal has an "Update via browser only (no SSH)" checkbox; leaving it unchecked makes selecting an SSH profile mandatory. We wanted to prevent saving a site that satisfies neither option — no profile selected, and the checkbox left unchecked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toggleSSHFields&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isBrowserOnly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;browser_only_check&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileSelect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server_profile_select&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isBrowserOnly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Browser-only mode doesn't need a profile&lt;/span&gt;
        &lt;span class="nx"&gt;profileSelect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;profileSelect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// SSH mode requires a profile&lt;/span&gt;
        &lt;span class="nx"&gt;profileSelect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&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;The &lt;code&gt;required&lt;/code&gt; attribute is toggled dynamically based on the checkbox state, and the rest is handled by the cross-tab validation logic in &lt;code&gt;saveSite&lt;/code&gt; described above. Trying to save without a profile selected automatically opens the "SSH" tab and shows the error bubble right on the profile dropdown. The user never has to hunt for why saving isn't working.&lt;/p&gt;

&lt;h2&gt;
  
  
  A side benefit: accessibility via ARIA
&lt;/h2&gt;

&lt;p&gt;Alongside the tab split, we added &lt;code&gt;role="tablist"&lt;/code&gt; / &lt;code&gt;role="tab"&lt;/code&gt; / &lt;code&gt;role="tabpanel"&lt;/code&gt; and &lt;code&gt;aria-selected&lt;/code&gt; / &lt;code&gt;aria-controls&lt;/code&gt; / &lt;code&gt;aria-labelledby&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"site-tabs"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"tablist"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Site edit tabs"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"site-tab-btn active"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"tab"&lt;/span&gt;
            &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"site-tab-btn-info"&lt;/span&gt; &lt;span class="na"&gt;aria-selected=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
            &lt;span class="na"&gt;aria-controls=&lt;/span&gt;&lt;span class="s"&gt;"site-tab-panel-info"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Registration info&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- SSH and WordPress info tabs follow the same pattern --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tab-switching JavaScript keeps &lt;code&gt;aria-selected&lt;/code&gt; in sync as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;switchSiteTab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.site-tab-btn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-tab-target&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tabName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-selected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isActive&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;'&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;It is not just a visual toggle — screen reader users also get told which tab is currently selected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Splitting a form into tabs feels like an intuitive cleanup, but it can quietly break an assumption the browser's built-in validation depends on: that an invalid field is always visible on screen. Disabling the automatic block with &lt;code&gt;novalidate&lt;/code&gt;, keeping the constraint checks alive through &lt;code&gt;checkValidity()&lt;/code&gt; / &lt;code&gt;:invalid&lt;/code&gt;, and making the failing field visible before calling &lt;code&gt;reportValidity()&lt;/code&gt; — that small extra step is what lets a tabbed UI coexist with standard form validation. It's a modest but broadly reusable pattern for any design that splits a form across multiple views.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
