diff --git a/README.md b/README.md
index 4200858ca801a83a73719c5015ae1f542f9c1544..18da30bea5da90cff4092cb244b350726d0c2725 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ backported to the C version, depending on how viable a proper Zig release is.
 
 Missing features:
 
-- Export/import
+- File import
 - Most directory listing settings
 - Lots of informational UI windows
 - Directory refresh
@@ -72,6 +72,7 @@ Aside from this implementation being unfinished:
 - Listing all paths for a particular hard link requires a full search through
   the in-memory directory tree.
 - Not nearly as well tested.
+- Directories that could not be opened are displayed as files.
 
 ## Requirements
 
diff --git a/src/main.zig b/src/main.zig
index 44f06f40ce2a5507eef3e114a1144fa2293aec83..b12d31e8589db6e39643c5e00f5336285a60b3fa 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -160,7 +160,8 @@ fn readExcludeFile(path: []const u8) !void {
     }
 }
 
-pub fn main() anyerror!void {
+// TODO: Better error reporting
+pub fn main() !void {
     // Grab thousands_sep from the current C locale.
     // (We can safely remove this when not linking against libc, it's a somewhat obscure feature)
     _ = c.setlocale(c.LC_ALL, "");
@@ -217,21 +218,26 @@ pub fn main() anyerror!void {
     if (std.builtin.os.tag != .linux and config.exclude_kernfs)
         ui.die("The --exclude-kernfs tag is currently only supported on Linux.\n", .{});
 
-    const is_out_tty = std.io.getStdOut().isTty();
+    const out_tty = std.io.getStdOut().isTty();
     if (!has_scan_ui) {
         if (export_file) |f| {
-            if (!is_out_tty or std.mem.eql(u8, f, "-")) config.scan_ui = .none
+            if (!out_tty or std.mem.eql(u8, f, "-")) config.scan_ui = .none
             else config.scan_ui = .line;
         }
     }
-    if (!is_out_tty and (export_file == null or config.scan_ui != .none))
-        ui.die("Standard output is not a TTY, can't initialize ncurses UI.\n", .{});
+    config.nc_tty = if (export_file) |f| std.mem.eql(u8, f, "-") else false;
 
     event_delay_timer = try std.time.Timer.start();
     defer ui.deinit();
 
+    var out_file = if (export_file) |f| (
+        if (std.mem.eql(u8, f, "-")) std.io.getStdOut()
+        else try std.fs.cwd().createFile(f, .{})
+    ) else null;
+
     state = .scan;
-    try scan.scanRoot(scan_dir orelse ".");
+    try scan.scanRoot(scan_dir orelse ".", out_file);
+    if (out_file != null) return;
 
     config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode.
     ui.init();
diff --git a/src/scan.zig b/src/scan.zig
index 118750b1e700bb8334600cf6f4e6d0ad1ddc10eb..96978ed66f35c6b76089389a0b29ca07d5b39efa 100644
--- a/src/scan.zig
+++ b/src/scan.zig
@@ -96,8 +96,51 @@ fn isKernfs(dir: std.fs.Dir, dev: u64) bool {
     return iskern;
 }
 
+// Output a JSON string.
+// Could use std.json.stringify(), but that implementation is "correct" in that
+// it refuses to encode non-UTF8 slices as strings. Ncdu dumps aren't valid
+// JSON if we have non-UTF8 filenames, such is life...
+fn writeJsonString(wr: anytype, s: []const u8) !void {
+    try wr.writeByte('"');
+    for (s) |ch| {
+        switch (ch) {
+            '\n' => try wr.writeAll("\\n"),
+            '\r' => try wr.writeAll("\\r"),
+            0x8  => try wr.writeAll("\\b"),
+            '\t' => try wr.writeAll("\\t"),
+            0xC  => try wr.writeAll("\\f"),
+            '\\' => try wr.writeAll("\\\\"),
+            '"'  => try wr.writeAll("\\\""),
+            0...7, 0xB, 0xE...0x1F, 127 => try wr.print("\\u00{x:02}", .{ch}),
+            else => try wr.writeByte(ch)
+        }
+    }
+    try wr.writeByte('"');
+}
+
+// Scan/import context. Entries are added in roughly the following way:
+//
+//   ctx.pushPath(name)
+//   ctx.stat = ..;
+//   ctx.addSpecial() or ctx.addStat()
+//   if (is_dir) {
+//      // ctx.enterDir() is implicit in ctx.addStat() for directory entries.
+//      // repeat top-level steps for files in dir, recursively.
+//      ctx.leaveDir();
+//   }
+//   ctx.popPath();
+//
+// (Multithreaded scanning note: when scanning to RAM, we can support multiple
+// of these Contexts in parallel, just need to make sure to lock any access to
+// model.* related functions. Parallel scanning to a file will require major
+// changes to the export format or buffering with specially guided scanning to
+// avoid buffering /everything/... neither seems fun)
 const Context = struct {
-    parents: model.Parents = .{},
+    // When scanning to RAM
+    parents: ?*model.Parents = null,
+    // When scanning to a file
+    wr: ?std.io.BufferedWriter(4096, std.fs.File.Writer).Writer = null,
+
     path: std.ArrayList(u8) = std.ArrayList(u8).init(main.allocator),
     path_indices: std.ArrayList(usize) = std.ArrayList(usize).init(main.allocator),
     items_seen: u32 = 1,
@@ -108,8 +151,11 @@ const Context = struct {
 
     last_error: ?[:0]u8 = null,
 
+    stat: Stat = undefined,
+
     const Self = @This();
 
+    // Add the name of the file/dir entry we're currently inspecting
     fn pushPath(self: *Self, name: []const u8) !void {
         try self.path_indices.append(self.path.items.len);
         if (self.path.items.len > 1) try self.path.append('/');
@@ -119,6 +165,9 @@ const Context = struct {
         try self.path.append(0);
         self.name = self.path.items[start..self.path.items.len-1:0];
         self.path.items.len -= 1;
+
+        self.items_seen += 1;
+        self.stat.dir = false; // used by addSpecial(); if we've failed to stat() then don't consider it a dir.
     }
 
     fn popPath(self: *Self) void {
@@ -132,34 +181,109 @@ const Context = struct {
         return self.path.items[0..self.path.items.len-1:0];
     }
 
-    // Insert the current path as an error entry
-    fn setError(self: *Self) !void {
-        var e = try model.Entry.create(.file, false, self.name);
-        e.insert(&self.parents) catch unreachable;
-        e.set_err(&self.parents);
+    // Set a flag to indicate that there was an error listing file entries in the current directory.
+    // (Such errors are silently ignored when exporting to a file, as the directory metadata has already been written)
+    fn setDirlistError(self: *Self) void {
+        if (self.parents) |p| p.top().entry.set_err(p);
+    }
+
+    const Special = enum { err, other_fs, kernfs, excluded };
 
-        if (self.last_error) |p| main.allocator.free(p);
-        self.last_error = try main.allocator.dupeZ(u8, self.path.items);
+    // Insert the current path as a special entry (i.e. a file/dir that is not counted)
+    fn addSpecial(self: *Self, t: Special) !void {
+        if (t == .err) {
+            if (self.last_error) |p| main.allocator.free(p);
+            self.last_error = try main.allocator.dupeZ(u8, self.path.items);
+        }
+
+        if (self.parents) |p| {
+            var e = try model.Entry.create(.file, false, self.name);
+            e.insert(p) catch unreachable;
+            var f = e.file().?;
+            switch (t) {
+                .err => e.set_err(p),
+                .other_fs => f.other_fs = true,
+                .kernfs => f.kernfs = true,
+                .excluded => f.excluded = true,
+            }
+        }
+
+        if (self.wr) |w| {
+            try w.writeAll(",\n");
+            if (self.stat.dir) try w.writeByte('[');
+            try w.writeAll("{\"name\":");
+            try writeJsonString(w, self.name);
+            switch (t) {
+                .err => try w.writeAll(",\"read_error\":true"),
+                .other_fs => try w.writeAll(",\"excluded\":\"othfs\""),
+                .kernfs => try w.writeAll(",\"excluded\":\"kernfs\""),
+                .excluded => try w.writeAll(",\"excluded\":\"pattern\""),
+            }
+            try w.writeByte('}');
+            if (self.stat.dir) try w.writeByte(']');
+        }
+    }
+
+    // Insert current path as a counted file/dir/hardlink, with information from self.stat
+    fn addStat(self: *Self, dir_dev: u64) !void {
+        if (self.parents) |p| {
+            const etype = if (self.stat.dir) model.EType.dir
+                          else if (self.stat.nlink > 1) model.EType.link
+                          else model.EType.file;
+            var e = try model.Entry.create(etype, main.config.extended, self.name);
+            e.blocks = self.stat.blocks;
+            e.size = self.stat.size;
+            if (e.dir()) |d| d.dev = try model.getDevId(self.stat.dev);
+            if (e.file()) |f| f.notreg = !self.stat.dir and !self.stat.reg;
+            if (e.link()) |l| {
+                l.ino = self.stat.ino;
+                l.nlink = self.stat.nlink;
+            }
+            if (e.ext()) |ext| ext.* = self.stat.ext;
+            try e.insert(p);
+
+            if (e.dir()) |d| try p.push(d); // Enter the directory
+        }
+
+        if (self.wr) |w| {
+            try w.writeAll(",\n");
+            if (self.stat.dir) try w.writeByte('[');
+            try w.writeAll("{\"name\":");
+            try writeJsonString(w, self.name);
+            if (self.stat.size > 0) try w.print(",\"asize\":{d}", .{ self.stat.size });
+            if (self.stat.blocks > 0) try w.print(",\"dsize\":{d}", .{ blocksToSize(self.stat.blocks) });
+            if (self.stat.dir and self.stat.dev != dir_dev) try w.print(",\"dev\":{d}", .{ self.stat.dev });
+            if (!self.stat.dir and self.stat.nlink > 1) try w.print(",\"ino\":{d},\"hlnkc\":true,\"nlink\":{d}", .{ self.stat.ino, self.stat.nlink });
+            if (!self.stat.dir and !self.stat.reg) try w.writeAll(",\"notreg\":true");
+            if (main.config.extended)
+                try w.print(",\"uid\":{d},\"gid\":{d},\"mode\":{d},\"mtime\":{d}",
+                    .{ self.stat.ext.uid, self.stat.ext.gid, self.stat.ext.mode, self.stat.ext.mtime });
+            try w.writeByte('}');
+        }
+    }
+
+    fn leaveDir(self: *Self) !void {
+        if (self.parents) |p| p.pop();
+        if (self.wr) |w| try w.writeByte(']');
     }
 };
 
 // Context that is currently being used for scanning.
 var active_context: ?*Context = null;
 
-// Read and index entries of the given dir. The entry for the directory is already assumed to be in 'ctx.parents'.
+// Read and index entries of the given dir.
 // (TODO: shouldn't error on OOM but instead call a function that waits or something)
-fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void {
+fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) (std.fs.File.Writer.Error || std.mem.Allocator.Error)!void {
     var it = dir.iterate();
     while(true) {
         const entry = it.next() catch {
-            ctx.parents.top().entry.set_err(&ctx.parents);
+            ctx.setDirlistError();
             return;
         } orelse break;
-        ctx.items_seen += 1;
 
         try ctx.pushPath(entry.name);
-        try main.handleEvent(false, false);
         defer ctx.popPath();
+        try main.handleEvent(false, false);
 
         // XXX: This algorithm is extremely slow, can be optimized with some clever pattern parsing.
         const excluded = blk: {
@@ -174,102 +298,103 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir) std.mem.Allocator.Error!void {
             break :blk false;
         };
         if (excluded) {
-            var e = try model.Entry.create(.file, false, entry.name);
-            e.file().?.excluded = true;
-            e.insert(&ctx.parents) catch unreachable;
+            try ctx.addSpecial(.excluded);
             continue;
         }
 
-        var stat = Stat.read(dir, ctx.name, false) catch {
-            try ctx.setError();
+        ctx.stat = Stat.read(dir, ctx.name, false) catch {
+            try ctx.addSpecial(.err);
             continue;
         };
 
-        if (main.config.same_fs and stat.dev != model.getDev(ctx.parents.top().dev)) {
-            try ctx.setError();
+        if (main.config.same_fs and ctx.stat.dev != dir_dev) {
+            try ctx.addSpecial(.other_fs);
             continue;
         }
 
-        if (main.config.follow_symlinks and stat.symlink) {
+        if (main.config.follow_symlinks and ctx.stat.symlink) {
             if (Stat.read(dir, ctx.name, true)) |nstat| {
                 if (!nstat.dir) {
-                    stat = nstat;
+                    ctx.stat = nstat;
                     // Symlink targets may reside on different filesystems,
                     // this will break hardlink detection and counting so let's disable it.
-                    if (stat.nlink > 1 and stat.dev != model.getDev(ctx.parents.top().dev))
-                        stat.nlink = 1;
+                    if (ctx.stat.nlink > 1 and ctx.stat.dev != dir_dev)
+                        ctx.stat.nlink = 1;
                 }
             } else |_| {}
         }
 
         var edir =
-            if (stat.dir) dir.openDirZ(ctx.name, .{ .access_sub_paths = true, .iterate = true, .no_follow = true }) catch {
-                try ctx.setError();
+            if (ctx.stat.dir) dir.openDirZ(ctx.name, .{ .access_sub_paths = true, .iterate = true, .no_follow = true }) catch {
+                try ctx.addSpecial(.err);
                 continue;
             } else null;
         defer if (edir != null) edir.?.close();
 
-        if (std.builtin.os.tag == .linux and main.config.exclude_kernfs and stat.dir and isKernfs(edir.?, stat.dev)) {
-            var e = try model.Entry.create(.file, false, entry.name);
-            e.file().?.kernfs = true;
-            e.insert(&ctx.parents) catch unreachable;
+        if (std.builtin.os.tag == .linux and main.config.exclude_kernfs and ctx.stat.dir and isKernfs(edir.?, ctx.stat.dev)) {
+            try ctx.addSpecial(.kernfs);
             continue;
         }
 
-        if (main.config.exclude_caches and stat.dir) {
+        if (main.config.exclude_caches and ctx.stat.dir) {
             if (edir.?.openFileZ("CACHEDIR.TAG", .{})) |f| {
                 const sig = "Signature: 8a477f597d28d172789f06886806bc55";
                 var buf: [sig.len]u8 = undefined;
                 if (f.reader().readAll(&buf)) |len| {
                     if (len == sig.len and std.mem.eql(u8, &buf, sig)) {
-                        var e = try model.Entry.create(.file, false, entry.name);
-                        e.file().?.excluded = true;
-                        e.insert(&ctx.parents) catch unreachable;
+                        try ctx.addSpecial(.excluded);
                         continue;
                     }
                 } else |_| {}
             } else |_| {}
         }
 
-        const etype = if (stat.dir) model.EType.dir else if (stat.nlink > 1) model.EType.link else model.EType.file;
-        var e = try model.Entry.create(etype, main.config.extended, entry.name);
-        e.blocks = stat.blocks;
-        e.size = stat.size;
-        if (e.dir()) |d| d.dev = try model.getDevId(stat.dev);
-        if (e.file()) |f| f.notreg = !stat.dir and !stat.reg;
-        if (e.link()) |l| {
-            l.ino = stat.ino;
-            l.nlink = stat.nlink;
-        }
-        if (e.ext()) |ext| ext.* = stat.ext;
-        try e.insert(&ctx.parents);
+        try ctx.addStat(dir_dev);
 
-        if (e.dir()) |d| {
-            try ctx.parents.push(d);
-            try scanDir(ctx, edir.?);
-            ctx.parents.pop();
+        if (ctx.stat.dir) {
+            try scanDir(ctx, edir.?, ctx.stat.dev);
+            try ctx.leaveDir();
         }
     }
 }
 
-pub fn scanRoot(path: []const u8) !void {
+pub fn scanRoot(path: []const u8, out: ?std.fs.File) !void {
     const full_path = std.fs.realpathAlloc(main.allocator, path) catch path;
-    model.root = (try model.Entry.create(.dir, false, full_path)).dir().?;
-
-    const stat = try Stat.read(std.fs.cwd(), model.root.entry.name(), true);
-    if (!stat.dir) return error.NotADirectory;
-    model.root.entry.blocks = stat.blocks;
-    model.root.entry.size = stat.size;
-    model.root.dev = try model.getDevId(stat.dev);
-    if (model.root.entry.ext()) |ext| ext.* = stat.ext;
 
     var ctx = Context{};
     try ctx.pushPath(full_path);
-    const dir = try std.fs.cwd().openDirZ(model.root.entry.name(), .{ .access_sub_paths = true, .iterate = true });
-
     active_context = &ctx;
     defer active_context = null;
-    try scanDir(&ctx, dir);
+
+    ctx.stat = try Stat.read(std.fs.cwd(), ctx.pathZ(), true);
+    if (!ctx.stat.dir) return error.NotADirectory;
+
+    var parents = model.Parents{};
+    var buf = if (out) |f| std.io.bufferedWriter(f.writer()) else undefined;
+
+    if (out) |f| {
+        ctx.wr = buf.writer();
+        try ctx.wr.?.writeAll("[1,2,{\"progname\":\"ncdu\",\"progver\":\"" ++ main.program_version ++ "\",\"timestamp\":");
+        try ctx.wr.?.print("{d}", .{std.time.timestamp()});
+        try ctx.wr.?.writeByte('}');
+        try ctx.addStat(0);
+
+    } else {
+        ctx.parents = &parents;
+        model.root = (try model.Entry.create(.dir, false, full_path)).dir().?;
+        model.root.entry.blocks = ctx.stat.blocks;
+        model.root.entry.size = ctx.stat.size;
+        model.root.dev = try model.getDevId(ctx.stat.dev);
+        if (model.root.entry.ext()) |ext| ext.* = ctx.stat.ext;
+    }
+
+    const dir = try std.fs.cwd().openDirZ(ctx.pathZ(), .{ .access_sub_paths = true, .iterate = true });
+    try scanDir(&ctx, dir, ctx.stat.dev);
+    if (out != null) {
+        try ctx.leaveDir();
+        try ctx.wr.?.writeByte(']');
+        try buf.flush();
+    }
 }
 
 var animation_pos: u32 = 0;
@@ -284,7 +409,7 @@ fn drawBox() !void {
     ui.addstr("Total items: ");
     ui.addnum(.default, ctx.items_seen);
 
-    if (width > 48 and true) { // TODO: When not exporting to file
+    if (width > 48 and ctx.parents != null) {
         box.move(2, 30);
         ui.addstr("size: ");
         ui.addsize(.default, blocksToSize(model.root.entry.blocks));
@@ -345,7 +470,7 @@ pub fn draw() !void {
         .line => {
             var buf: [256]u8 = undefined;
             var line: []const u8 = undefined;
-            if (false) { // TODO: When exporting to file; no total size known
+            if (active_context.?.parents == null) {
                 line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <63} {d:>9} files\x1b8",
                     .{ ui.shorten(active_context.?.pathZ(), 63), active_context.?.items_seen }
                 ) catch return;
diff --git a/src/ui.zig b/src/ui.zig
index 30b4657ed1687464b37811ee124f8b2517713153..d8721883ea53a5850c0d46bab9d5a7153177f4c2 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -266,11 +266,15 @@ fn updateSize() void {
     cols = @intCast(u32, c.getmaxx(c.stdscr));
 }
 
-pub fn init() void {
-    if (inited) return;
+fn clearScr() void {
     // Send a "clear from cursor to end of screen" instruction, to clear a
     // potential line left behind from scanning in -1 mode.
     _ = std.io.getStdErr().write("\x1b[J") catch {};
+}
+
+pub fn init() void {
+    if (inited) return;
+    clearScr();
     if (main.config.nc_tty) {
         var tty = c.fopen("/dev/tty", "r+");
         if (tty == null) die("Error opening /dev/tty: {s}.\n", .{ c.strerror(std.c.getErrno(-1)) });
@@ -296,7 +300,10 @@ pub fn init() void {
 }
 
 pub fn deinit() void {
-    if (!inited) return;
+    if (!inited) {
+        clearScr();
+        return;
+    }
     _ = c.erase();
     _ = c.refresh();
     _ = c.endwin();