<?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: Daniil Gruzdev</title>
    <description>The latest articles on DEV Community by Daniil Gruzdev (@daniil_gruzdev_456e86a393).</description>
    <link>https://dev.to/daniil_gruzdev_456e86a393</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%2F4058675%2F1f8f9b1e-1df5-4e52-bada-b55c569555a9.png</url>
      <title>DEV Community: Daniil Gruzdev</title>
      <link>https://dev.to/daniil_gruzdev_456e86a393</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniil_gruzdev_456e86a393"/>
    <language>en</language>
    <item>
      <title>bindantic: describe BIND9 config as Pydantic models</title>
      <dc:creator>Daniil Gruzdev</dc:creator>
      <pubDate>Sun, 02 Aug 2026 07:54:12 +0000</pubDate>
      <link>https://dev.to/daniil_gruzdev_456e86a393/bindantic-describe-bind9-config-as-pydantic-models-1dm2</link>
      <guid>https://dev.to/daniil_gruzdev_456e86a393/bindantic-describe-bind9-config-as-pydantic-models-1dm2</guid>
      <description>&lt;p&gt;If you've ever administered BIND9, you know the feeling: you open named.conf, add a zone, forget the trailing dot on a domain name or a semicolon after a block - and named-checkconf (if you remembered to run it at all) quietly makes you read three different flavors of "syntax error" at once.&lt;/p&gt;

&lt;p&gt;Maybe you've already moved on to Route53/Cloudflare/PowerDNS and similar managed setups? Fair enough - but BIND9 hasn't gone anywhere: self-hosted DNS, private zones, legacy infrastructure, requirements to keep everything on-prem - there's no shortage of reasons to still be running it.&lt;/p&gt;

&lt;p&gt;named.conf is, well, configuration without a schema. No typing, no validation while you write it - just hoping you remember the exact syntax for &lt;code&gt;allow-transfer { key tsig-key; };&lt;/code&gt; by heart.&lt;/p&gt;

&lt;p&gt;That's how I ended up building bindantic (bind + pydantic, you get it) - a library that describes BIND9 configuration as Pydantic models and generates correct named.conf/zone-file syntax from them. Just shipped 1.0.0, so here's what it does, why, and what broke along the way.&lt;/p&gt;

&lt;p&gt;Never published anything like this before, but figured it's worth a shot - curious to see the reaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&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;bindantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ARecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NamedConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NSRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OptionsBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SOARecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZoneBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ZoneTypeEnum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NamedConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;options_block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;OptionsBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/bind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recursion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;allow_recursion&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;localhost&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;localnets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;listen_on&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;any&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;listen_on_v6&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;any&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zone_blocks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;ZoneBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;zone_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ZoneTypeEnum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRIMARY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zones/example.com.zone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;resource_records&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nc"&gt;SOARecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;mname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ns1.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;serial&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2026010101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry&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;expire&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;604800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minimum&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="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;NSRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nsdname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ns1.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nc"&gt;ARecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.1.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_bind_syntax&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints a valid named.conf. Typo an IP, miss a required SOA field, or set an option that's incompatible with the zone type, and Pydantic tells you immediately when you build the model - not when BIND refuses to reload in production.&lt;/p&gt;

&lt;p&gt;Out of the box: every named.conf block (acl, options, zone, view, dnssec-policy, tls, key, logging, remote-servers, ...) and every common resource record type - A, AAAA, MX, NS, SOA, TXT, CAA, DNSKEY, RRSIG, TLSA, SSHFP, and a dozen more. config.write_files() lays out named.conf, zone files, keys, and DNSSEC policies into the right directories for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's different from what's already out there
&lt;/h2&gt;

&lt;p&gt;bind9_parser exists in the Python ecosystem, but it solves the reverse problem: it parses an existing named.conf. bindantic goes the other direction - you write the config in Python from scratch, with validation and IDE autocomplete, instead of writing text that gets parsed afterward.&lt;/p&gt;

&lt;p&gt;Worth being upfront about where bindantic does not fit, too. If a zone is dynamic - nsupdate is allowed, a secondary is pulling updates via IXFR, dnssec-policy/inline-signing is on - BIND maintains its own .jnl journal and periodically flushes changes back into the zone file, rewriting it completely (often in raw format), with no regard for whatever formatting or comments were there before. So bindantic is a good fit for generating the "day zero" state at bootstrap, or for fully regenerating the config from scratch on every deploy (the IaC way - the zone is a build artifact, not a place for manual edits on top), but not for round-tripping with a zone BIND itself is actively mutating. If you need to read the current live state before regenerating, that's exactly the niche the bind9_parser mentioned above fills - the two tools probably complement each other more than compete.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Out of the box: 96%+ test coverage, static typing (py.typed), a CI matrix across Python 3.10-3.13, and an auto-generated API reference for every model. No dependencies beyond Pydantic.&lt;/p&gt;

&lt;p&gt;But string-based tests only verify what you thought to check - they don't guarantee BIND will actually accept the resulting config. So CI has a dedicated job that runs the generated named.conf through named-checkconf against a real BIND 9.20 install. That's exactly what caught the bugs below - literally right before the 1.0 release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fun part: bugs found while prepping for 1.0
&lt;/h2&gt;

&lt;p&gt;Three genuinely nasty bugs surfaced:&lt;/p&gt;

&lt;p&gt;View order was silently getting scrambled. For BIND, view order isn't cosmetic - the server applies the first matching view and stops. But model_bind_syntax() sorted view_blocks before rendering, since for most blocks (acl, key, zone) output order doesn't matter and sorting gives stable diffs. Turns out it does matter for views: ViewBlock never overrode comparison_attr, so the comparison always returned True, and sorted() effectively reversed the list. A split-horizon config with an internal view before external could come out in the opposite order - inverting who sees what.&lt;/p&gt;

&lt;p&gt;key  in an ACL rendered with bogus quotes. allow-transfer { key tsig-key; } is completely standard for TSIG-secured zone transfers. The generator wrapped it in quotes instead: "key tsig-key"; - invalid syntax that named-checkconf rejected outright. The shared formatter saw a space inside the string and decided to quote it, unaware that key  is a special two-token grammar element, not a string literal.&lt;/p&gt;

&lt;p&gt;Record owner names got force-qualified with a trailing dot. This one was the nastiest. The name field on a resource record shared a validator with fields that reference other domains (nsdname, mname, exchange) - those are supposed to always be fully-qualified, so the validator appends a dot. Correct for those. Wrong for the record's own owner name: a relative label like ns1 became ns1. - an absolute, root-level name instead of ns1.example.com. BIND flagged it as "out-of-zone data" and refused to load the zone. It broke essentially any record except @.&lt;/p&gt;

&lt;p&gt;All three lived quietly in the code until I sat down to write realistic examples (split-horizon, TSIG transfers, DNSSEC) and ran them through a real named-checkconf instead of trusting string-based unit tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: github.com/DVSAWR/bindantic&lt;br&gt;
&lt;strong&gt;Docs&lt;/strong&gt;: dvsawr.github.io/bindantic&lt;br&gt;
&lt;strong&gt;PyPI&lt;/strong&gt;: pypi.org/project/bindantic&lt;/p&gt;

&lt;p&gt;Feedback, issues, and PRs are genuinely welcome, criticism included. If you've got automation around BIND9 - provisioning scripts, an internal Ansible module, IaC for your DNS infrastructure - bindantic might slot in as the config-generation layer instead of Jinja2 templating or hand-rolled string concatenation. Curious how well it'd actually fit into an existing pipeline.&lt;/p&gt;

&lt;p&gt;How do you currently handle DNS config delivery at your place - a templating engine, a homegrown generator, hand edits through configuration management, something else? Curious to compare notes in the comments.&lt;/p&gt;

</description>
      <category>bind9</category>
      <category>dns</category>
      <category>python</category>
    </item>
  </channel>
</rss>
