bastiak: Yup, I haven't fixed it right
sebix: As binary mode is default for Py2, I think you have to check for non-existance of `t` in mode for py2. `f.mode` is exactly the used parameter, without default values.
bastiak: I'm not sure if use except TypeError is the best practise with handling boolean or textual streams, but it works. Also I' don't trust too much to .decode() method used there. Any suggestions are welcome
sebix: Thanks for all your efforts and explanations! Why is writing files so hard...?

To ensure that all python versions behave as you expect you could add some test checking for this behavior. One for binary and one for textual mode and then check for (not) raised TypeError.
diff --git a/dns/zone.py b/dns/zone.py
index 4a73e1e..1b5dca2 100644
--- a/dns/zone.py
+++ b/dns/zone.py
@@ -19,6 +19,7 @@ from __future__ import generators
 
 import sys
 import re
+import os
 from io import BytesIO
 
 import dns.exception
@@ -498,18 +499,27 @@ class Zone(object):
         @type nl: string or None
         """
 
-        str_type = string_types
+        if isinstance(f, string_types):
+            f = open(f, 'wb')
+            want_close = True
+        else:
+            want_close = False
+
+        # must be in this way, f.encoding may contain None, or even attribute
+        # may not be there
+        file_enc = getattr(f, 'encoding', None)
+        if file_enc is None:
+            file_enc = 'utf-8'
 
         if nl is None:
-            opts = 'wb'
+            nl_b = os.linesep.encode(file_enc)  # binary mode, '\n' is not enough
+            nl = u'\n'
+        elif isinstance(nl, string_types):
+            nl_b = nl.encode(file_enc)
         else:
-            opts = 'wb'
+            nl_b = nl
+            nl = nl.decode()
 
-        if isinstance(f, str_type):
-            f = open(f, opts)
-            want_close = True
-        else:
-            want_close = False
         try:
             if sorted:
                 names = list(self.keys())
@@ -520,11 +530,15 @@ class Zone(object):
                 l = self[n].to_text(n, origin=self.origin,
                                     relativize=relativize)
                 if isinstance(l, text_type):
-                    l = l.encode()
-                if nl is None:
-                    f.write(l)
-                    f.write('\n')
+                    l_b = l.encode(file_enc)
                 else:
+                    l_b = l
+                    l = l.decode()
+
+                try:
+                    f.write(l_b)
+                    f.write(nl_b)
+                except TypeError:  # textual mode
                     f.write(l)
                     f.write(nl)
         finally:

py3: Zone.to_file failed
Hello,

```
#!/usr/local/bin/python3

import dns.zone
from dns.rdatatype import SOA

zone_obj = dns.zone.Zone(dns.name.from_text('test.zone'))
zone_obj.find_rdataset('@', rdtype=SOA, create=True)

zone_obj.to_file(open('/dev/null', 'w'))

Traceback (most recent call last):
  File "./t.py", line 9, in <module>
    zone_obj.to_file(open('/dev/null', 'w'))
  File "/opt/hosting/software/python3/lib/python3.5/site-packages/dns/zone.py", line 516, in to_file
    f.write(l)
TypeError: write() argument must be str, not bytes

zone_obj.to_file(open('/dev/null', 'wb'))

Traceback (most recent call last):
  File "./t.py", line 10, in <module>
    zone_obj.to_file(open('/dev/null', 'wb'))
  File "/opt/hosting/software/python3/lib/python3.5/site-packages/dns/zone.py", line 517, in to_file
    f.write('\n')
TypeError: a bytes-like object is required, not 'str'
```

looks like a bug ?
diff --git a/tests/test_zone.py b/tests/test_zone.py
index 712b590..3d53e93 100644
--- a/tests/test_zone.py
+++ b/tests/test_zone.py
@@ -132,6 +132,59 @@ class ZoneTestCase(unittest.TestCase):
                 os.unlink(here('example2.out'))
         self.failUnless(ok)
 
+    def testToFileTextualStream(self):
+        z = dns.zone.from_text(example_text, 'example.', relativize=True)
+        f = StringIO()
+        z.to_file(f)
+        out = f.getvalue()
+        f.close()
+        self.assertEqual(out, example_text_output)
+
+    def testToFileBinaryStream(self):
+        z = dns.zone.from_text(example_text, 'example.', relativize=True)
+        f = BytesIO()
+        z.to_file(f)
+        out = f.getvalue()
+        f.close()
+        self.assertEqual(out, example_text_output.encode())
+
+    def testToFileTextual(self):
+        z = dns.zone.from_file(here('example'), 'example')
+        try:
+            f = open(here('example3-textual.out'), 'w')
+            z.to_file(f)
+            f.close()
+            ok = filecmp.cmp(here('example3-textual.out'),
+                             here('example3.good'))
+        finally:
+            if not _keep_output:
+                os.unlink(here('example3-textual.out'))
+        self.failUnless(ok)
+
+    def testToFileBinary(self):
+        z = dns.zone.from_file(here('example'), 'example')
+        try:
+            f = open(here('example3-binary.out'), 'wb')
+            z.to_file(f)
+            f.close()
+            ok = filecmp.cmp(here('example3-binary.out'),
+                             here('example3.good'))
+        finally:
+            if not _keep_output:
+                os.unlink(here('example3-binary.out'))
+        self.failUnless(ok)
+
+    def testToFileFilename(self):
+        z = dns.zone.from_file(here('example'), 'example')
+        try:
+            z.to_file('example3-filename.out')
+            ok = filecmp.cmp(here('example3-filename.out'),
+                             here('example3.good'))
+        finally:
+            if not _keep_output:
+                os.unlink(here('example3-filename.out'))
+        self.failUnless(ok)
+
     def testToText(self):
         z = dns.zone.from_file(here('example'), 'example')
         ok = False

pip install -e .
parse_log_pytest
pytest
pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/rthalley/dnspython.git@188aa701a6826c607da0624e31a8c4618d0a8017#egg=dnspython
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1734472117206/work
pluggy @ file:///croot/pluggy_1733169602837/work
pytest @ file:///croot/pytest_1738938843180/work
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: dnspython
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
prefix: /opt/conda/envs/dnspython

tests/test_zone.py::ZoneTestCase::testToFileBinaryStream tests/test_zone.py::ZoneTestCase::testToFileTextualStream
tests/test_zone.py::ZoneTestCase::testFromFile1 tests/test_zone.py::ZoneTestCase::testFromFile2 tests/test_zone.py::ZoneTestCase::testInclude tests/test_zone.py::ZoneTestCase::testToFileBinary tests/test_zone.py::ZoneTestCase::testToFileFilename tests/test_zone.py::ZoneTestCase::testToFileTextual tests/test_zone.py::ZoneTestCase::testToText tests/test_zone.py::ZoneTestCase::testTorture1
tests/test_zone.py::ZoneTestCase::testBadDirective tests/test_zone.py::ZoneTestCase::testDeleteRdataset1 tests/test_zone.py::ZoneTestCase::testDeleteRdataset2 tests/test_zone.py::ZoneTestCase::testEqual tests/test_zone.py::ZoneTestCase::testFindRRset1 tests/test_zone.py::ZoneTestCase::testFindRRset2 tests/test_zone.py::ZoneTestCase::testFindRdataset1 tests/test_zone.py::ZoneTestCase::testFindRdataset2 tests/test_zone.py::ZoneTestCase::testFirstRRStartsWithWhitespace tests/test_zone.py::ZoneTestCase::testFromText tests/test_zone.py::ZoneTestCase::testGetRRset1 tests/test_zone.py::ZoneTestCase::testGetRRset2 tests/test_zone.py::ZoneTestCase::testGetRdataset1 tests/test_zone.py::ZoneTestCase::testGetRdataset2 tests/test_zone.py::ZoneTestCase::testIterateAllRdatas tests/test_zone.py::ZoneTestCase::testIterateAllRdatasets tests/test_zone.py::ZoneTestCase::testIterateRdatas tests/test_zone.py::ZoneTestCase::testIterateRdatasets tests/test_zone.py::ZoneTestCase::testNoNS tests/test_zone.py::ZoneTestCase::testNoSOA tests/test_zone.py::ZoneTestCase::testNodeDeleteRdataset1 tests/test_zone.py::ZoneTestCase::testNodeDeleteRdataset2 tests/test_zone.py::ZoneTestCase::testNodeFindRdataset1 tests/test_zone.py::ZoneTestCase::testNodeFindRdataset2 tests/test_zone.py::ZoneTestCase::testNodeGetRdataset1 tests/test_zone.py::ZoneTestCase::testNodeGetRdataset2 tests/test_zone.py::ZoneTestCase::testNotEqual1 tests/test_zone.py::ZoneTestCase::testNotEqual2 tests/test_zone.py::ZoneTestCase::testNotEqual3 tests/test_zone.py::ZoneTestCase::testReplaceRdataset1 tests/test_zone.py::ZoneTestCase::testReplaceRdataset2 tests/test_zone.py::ZoneTestCase::testTTLs tests/test_zone.py::ZoneTestCase::testZoneOrigin tests/test_zone.py::ZoneTestCase::testZoneOriginNone
dns/zone.py
dns/zone.py