JavaRush /Courses /Go SELF /Reliable CSV import/export: Flush/Error, Close()

Reliable CSV import/export: Flush/Error, Close()

Go SELF
Level 47, Lesson 4
Available

1. Why overwriting a CSV is risky, even if you’re confident

When we export for the first time, it’s very tempting to write something like: “opened "tasks.csv", wrote the rows, closed the file, done.” It looks logical—until reality shows up one day: the disk runs out of space, the write is interrupted, the process gets killed, the laptop goes to sleep, antivirus decides to “inspect” the file at the worst possible moment—and your CSV turns into a truncated fragment. The most annoying part is that a fragment often looks “almost correct” until someone tries to import it back.

Let’s imagine a naive export (the example is intentionally short to show the idea, not how to do it “right”):

package main

import (
	"os"
)

func main() {
	f, _ := os.Create("tasks.csv") // we “heroically” ignore errors
	_, _ = f.WriteString("id,title,done\n")
	_, _ = f.WriteString("1,Buy milk,false\n")
	_ = f.Close()
}

The problem here is not only ignoring errors (although that’s also a sin on the level of “I committed a password”). The main problem is that we write directly to the target file. If the write is cut off halfway through, the old data is already gone, and the new data isn’t ready yet.

2. The reliable write protocol: prepare first, publish second

If you want engineering-grade reliability, it helps to start thinking not “I wrote the file”, but “I performed a file update transaction”. This is where the protocol appears: we write the result into a separate temporary file, verify that writing finished without errors, close the file properly, and only then replace the old file with the new one using a single atomic action—renaming.

In a diagram it looks like this (not an art lesson—just a reminder for your brain):

flowchart TD
    A["There is an old tasks.csv file (or there isn't)"] --> B["Create a temp file next to it: export-*.csv"]
    B --> C["Write CSV into temp via csv.Writer"]
    C --> D["Flush() + check w.Error()"]
    D --> E["Close() the temp file and check the Close error"]
    E --> F["Publish: rename(temp -> tasks.csv) / with backup"]
    F --> G["Done: either the old file, or a fully new one"]

The key philosophy is simple: until the file is “published”, we treat it as a draft that we can delete without regret. And “publishing” is a single step that either works or doesn’t (with no in-between state).

One more important nuance: the temp file must be created in the same directory where the target file lives. Then os.Rename runs within the same filesystem, and we have a chance at an atomic rename (within the limits of the OS).

3. CSV checkpoints: why Write() doesn’t mean “written” yet

At some point every beginner learns that writing to a file is not always “the bytes are on disk right now”. CSV adds extra fun: csv.Writer buffers data, and some errors can show up not at Write time, but later—at Flush().

That’s why CSV export has a special ritual: after writing, call Flush() and then обязательно check w.Error().

It’s like you “sent” a letter (“Write”) and even sealed the envelope, but at the post office they tell you: “oops, the stamp is fake”— and you only find out at the counter (“Flush/Error”).

Mini-template for “properly finishing a CSV write”:

package main

import (
	"encoding/csv"
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("demo.csv")
	if err != nil {
		fmt.Println("create:", err)
		return
	}

	w := csv.NewWriter(f)
	_ = w.Write([]string{"id", "title"})
	_ = w.Write([]string{"1", "Buy milk"})

	w.Flush()
	if err := w.Error(); err != nil {
		fmt.Println("csv flush:", err)
	}
}

Yes, this still lacks a proper Close() and handling Write errors, but the checkpoint idea is already visible: a CSV write cannot be considered successful until Flush() and w.Error() have been done.

4. Finishing the write: Close() and backup

Close() is not a “formality”—it’s part of the contract

When you close a file, it’s like you tell the OS: “I’m done; clean up the loose ends.” And sometimes (rare, but real) errors surface specifically at Close() time: for example, accumulated write errors, buffer sync issues, quirks of network filesystems. That’s why “reliable export” usually includes checking the Close() error.

defer helps a lot here, because it’s designed exactly for “close the resource on exit”, and it’s a standard Go pattern.

But there’s a subtlety: if we need to check the error from Close(), then a simple defer f.Close() turns into “closed without checking”. So we often do a careful wrapper.

One of the most practical patterns (with a named err) that you’ll see in real Go code:

package storage

import "os"

func closeCarefully(f *os.File, err *error) {
	if cerr := f.Close(); cerr != nil && *err == nil {
		*err = cerr
	}
}

And we call it like this (note: we’re not “discussing pointers”; we’re just passing &err as “where to store it”—we’ve already covered that earlier in the course):

package storage

import (
	"os"
)

func doSomething() (err error) {
	f, err := os.Create("x.txt")
	if err != nil {
		return err
	}
	defer closeCarefully(f, &err)

	// ... write something into f ...
	return nil
}

Don’t turn this technique into magic: it exists only because Close() can return an error, and we don’t want to lose it. And yes, checking errors is normal: in Go it’s literally part of the culture, because “errors are values”.

Backup .bak: extra insurance during publishing

Backup is not a replacement for temp → rename; it’s an additional seatbelt. The temp → rename protocol protects us from a truncated file, but backup helps if something goes wrong during publishing, or if we want the option to manually restore the previous version.

The correct idea here is: it makes sense to create a backup not from the temp file, but from the current target file, right before publishing. That is, we’re effectively saying: “I’m about to replace "tasks.csv", but I’ll keep the old version as "tasks.csv.bak".”

Mini version of “replace file with backup” (simplified, but readable):

package storage

import (
	"fmt"
	"os"
)

func replaceWithBackup(dst, src string) error {
	bak := dst + ".bak"
	_ = os.Remove(bak)      // if there was an old .bak, remove it
	_ = os.Rename(dst, bak) // if dst didn't exist, ok: it will be an error, but not critical for us
	if err := os.Rename(src, dst); err != nil {
		_ = os.Rename(bak, dst) // rollback attempt
		return fmt.Errorf("publish: %w", err)
	}
	return nil
}

There is a deliberate bit of “boldness” here: os.Rename(dst, bak) may fail if dst does not exist. In this version we treat that calmly, because backup is optional insurance. In a stricter version you could distinguish “file does not exist” vs “real error”, but our goal right now is the protocol itself.

5. Example: reliable export of tasks to CSV

Now we’ll assemble everything into a coherent “recipe” for our training todo app. Suppose we already have this type:

package model

type Task struct {
	ID    int
	Title string
	Done  bool
}

First, we’ll make a small function that writes CSV to any io.Writer. This matters: it lets us write to a file, to a buffer, and to tests—with the same code (we’ve already seen the io.Writer idea earlier).

Encoding tasks to CSV and the Flush()/Error checkpoint

The intro is straightforward: we want to implement the CSV format once (header, column order, value order), and then reuse it for “export to file”, “export to memory”, and even tests. So we don’t open a file inside this function—we just write to whatever writer we’re given.

package csvio

import (
	"encoding/csv"
	"fmt"
	"io"
	"strconv"

	"example.com/todo/internal/model"
)

func EncodeTasks(w io.Writer, tasks []model.Task) error {
	cw := csv.NewWriter(w)
	if err := cw.Write([]string{"id", "title", "done"}); err != nil {
		return fmt.Errorf("write header: %w", err)
	}
	for _, t := range tasks {
		rec := []string{strconv.Itoa(t.ID), t.Title, strconv.FormatBool(t.Done)}
		if err := cw.Write(rec); err != nil {
			return fmt.Errorf("write record: %w", err)
		}
	}
	cw.Flush()
	if err := cw.Error(); err != nil {
		return fmt.Errorf("flush csv: %w", err)
	}
	return nil
}

Notice %w: we don’t just say “error!”, we wrap the cause so that the caller can understand what exactly broke. In Go this is standard practice, and %w is the official wrapping mechanism.

Reliable write: temp next to target, write, Close(), publish

Now we implement the “file protocol”. Here we use os.CreateTemp, filepath.Dir, cleanup, and publishing via rename/backup.

package storage

import (
	"fmt"
	"os"
	"path/filepath"
)

func ExportCSVAtomically(path string, write func(f *os.File) error) (err error) {
	dir := filepath.Dir(path)

	tmp, err := os.CreateTemp(dir, "export-*.csv")
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}
	tmpName := tmp.Name()
	cleanup := true
	defer func() {
		_ = tmp.Close() // best-effort
		if cleanup {
			_ = os.Remove(tmpName)
		}
	}()

	if err := write(tmp); err != nil {
		return err
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temp: %w", err)
	}

	if err := replaceWithBackup(path, tmpName); err != nil {
		return err
	}
	cleanup = false
	return nil
}

There are two important ideas here.

First: we intentionally accept write func(f *os.File) error so the function is universal. Today we write CSV; tomorrow JSON; the day after, some binary format—but the reliable write protocol remains the same.

Second: we close the file exactly once. We don’t do “both defer tmp.Close() and then manually tmp.Close() without control”, because that’s a common trap: you can get weird effects, and most importantly, you stop understanding where exactly you checked the close error.

Putting it together: exporting tasks to "tasks.csv"

Now we connect the two parts: “how to write CSV” and “how to safely write a file”.

package storage

import (
	"os"

	"example.com/todo/internal/csvio"
	"example.com/todo/internal/model"
)

func ExportTasksCSV(path string, tasks []model.Task) error {
	return ExportCSVAtomically(path, func(f *os.File) error {
		return csvio.EncodeTasks(f, tasks)
	})
}

And that’s it: your export is now “engineering-grade”. It either leaves the old file as-is, or replaces it with a fully written new version.

Reminder: where errors can actually surface during export

Sometimes a beginner thinks that a “write error” is a single point. In reality it can show up in several places, and the reliability protocol forces us to walk through all checkpoint steps.

Step What we do Where people often go wrong How to “close the step” correctly
1 Create a temp file create temp not next to the target
os.CreateTemp(filepath.Dir(path), ...)
2 Write CSV records ignore the write error
if err := w.Write(...); err != nil { ... }
3 Finalize CSV forget Flush() or w.Error()
w.Flush(); if err := w.Error(); err != nil { ... }
4 Close the file treat Close() as a “formality”
if err := f.Close(); err != nil { ... }
5 Publish write directly to the target
os.Rename(temp, target)
(often via backup)
6 Clean up temp remains after errors
defer os.Remove(temp)
with
cleanup=true

6. Import: validate data first, then touch the disk

With import there’s a moral trap: you want to read CSV and update the database/storage file “as you go”. But import is tricky precisely because it’s often partially bad: half the rows are fine, half are not. If you start mutating storage along the way, you get a mutant: data half new, half old— and then the user asks “what happened?”

The right approach sounds boring, but it works: first we read and validate everything, build the result in memory (or at least up to a logical checkpoint), and only if we like the final result do we publish using the same protocol.

Pseudo-skeleton (not a full import—just the framework for correctly controlling side effects):

package app

import (
	"fmt"

	"example.com/todo/internal/storage"
)

func ImportAndSave(csvPath, storePath string) error {
	tasks, err := ReadAndValidateCSV(csvPath) // there can be errors.Join(...) here
	if err != nil {
		return fmt.Errorf("import: %w", err)
	}
	if err := storage.ExportTasksCSV(storePath, tasks); err != nil {
		return fmt.Errorf("save: %w", err)
	}
	return nil
}

The point of this code is that the “dirty part” (a bad CSV) does not touch your “valuable data on disk”. First we produce a good result, and only then we save it reliably.

7. Common mistakes

Mistake #1: write CSV directly to the target file and hope “it won’t crash”.
It works right up until the first failure, after which you get a truncated file and lost old data. The only cure is the protocol: temp file next to the target, successful finalization, publish via rename.

Mistake #2: forget Flush() and w.Error() on csv.Writer.
The CSV writer buffers output, so an error may appear “at the end”, not when writing rows. If you skip Flush/Error, you can happily “successfully export” a file that was not actually written correctly.

Mistake #3: don’t check the Close() error and assume closing a file can’t fail.
In most cases Close() really does succeed quietly, but the reliable protocol is about “what if it doesn’t”. If you don’t check Close, sometimes you lose the only signal that the write finished incorrectly.

Mistake #4: do a “double Close”: both defer f.Close(), and then f.Close() manually, and then again in defer.
This mistake doesn’t always crash, but it almost always creates chaos in your head: it’s unclear where exactly you checked the close error, and where you just “closed for show”. Pick one strategy: either a defer without checking, or an explicit Close() with checking and careful cleanup.

Mistake #5: create the temp file in a “random” directory and then try to os.Rename into the working directory.
On different filesystems, Rename may not be atomic or may not work at all. So temp must be created next to the target file—in the same directory.

1
Task
Go SELF, level 47, lesson 4
Locked
CSV check
CSV check
1
Task
Go SELF, level 47, lesson 4
Locked
Cautious Close
Cautious Close
1
Task
Go SELF, level 47, lesson 4
Locked
Atomic replacement
Atomic replacement
1
Task
Go SELF, level 47, lesson 4
Locked
Reliable release
Reliable release
1
Survey/quiz
CSV, 47 level, 4 lesson
Unavailable
CSV
CSV: reading/writing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION