csvcopy

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 9 Imported by: 0

README ΒΆ

go-csv-copy

Streaming CSV reader for Go, shaped for bulk-loading into PostgreSQL. No dependencies outside the standard library.

βΈ»

πŸ“‹ Overview

The package reads CSV one row at a time: a row is read, handed over and forgotten, so memory does not depend on the size of the file.

What it is built for is COPY FROM β€” the row sources satisfy pgx.CopyFromSource, so a file of any size loads into Postgres without being buffered. That is the primary use case, not the only one: Reader is a plain CSV reader and Typed decodes into your own types, both usable with no database in sight. See Without a database.

Two ways of fixing a file's shape are supported, because both turn up in practice:

  • Raw β€” the shape comes from the file's own header. Whatever columns arrived, in their order, every value as text. This is the staging-table case, where the input's structure is not known ahead of time and a SQL script types the data afterwards.
  • Typed[S, D] β€” the shape comes from csv struct tags. Columns are matched by name, so their order in the file does not matter, extra columns are ignored, and a column a tag asks for and the file lacks is an error. Each row passes through your convert, which types and validates the values.
Why pgx is not imported

The package does not depend on pgx and is compatible with it anyway. Go interfaces are structural, and pgx.CopyFromSource is nothing more than a method set:

type CopyFromSource interface {
    Next() bool
    Values() ([]any, error)
    Err() error
}

Any type with those methods satisfies it without declaring a dependency. So the application picks the version of pgx, not the library, and upgrading pgx never requires upgrading csvcopy.

βΈ»

πŸ“¦ Install

go get github.com/AMUDENN/go-csv-copy
import "github.com/AMUDENN/go-csv-copy"   // package csvcopy

βΈ»

πŸš€ Quick start

Shape from the file header (staging table)
src, err := csvcopy.NewRaw(file)
if err != nil {
    return fmt.Errorf("parse input: %w", err)
}

columns := src.Columns()
if len(columns) == 0 {
    return nil // an empty file is not an error
}

if err := createStagingTable(ctx, tx, table, columns); err != nil {
    return err
}

n, err := tx.CopyFrom(ctx, pgx.Identifier{table}, columns, src)
if srcErr := src.Err(); srcErr != nil {
    return fmt.Errorf("parse input: %w", srcErr)
}
if err != nil {
    return fmt.Errorf("copy from: %w", err)
}

Check src.Err() before err: when the source fails on a row, pgx returns its own, less informative error, while the cause sits in the source together with the line number.

Shape from struct tags (typed load)
// The shape of the file: all text, tags name the columns in the header.
type clientRow struct {
    ID        string `csv:"client_id"`
    Email     string `csv:"email"`
    Balance   string `csv:"balance"`
    CreatedAt string `csv:"created_at"`
}

// Your typing and validation - the package stays out of it.
func (r *clientRow) toClient() (*Client, error) { /* ... */ }

var clientColumns = []string{"id", "email", "balance", "created_at"}

rows, err := csvcopy.NewTyped(file, (*clientRow).toClient)
if err != nil {
    return err
}

source := csvcopy.NewCopy(rows, len(clientColumns),
    func(dst []any, c *Client) []any {
        return append(dst, c.ID, c.Email, c.Balance, c.CreatedAt)
    })

_, err = tx.CopyFrom(ctx, pgx.Identifier{"clients_tmp"}, clientColumns, source)
if err != nil {
    if srcErr := source.Err(); srcErr != nil {
        err = srcErr
    }
    return fmt.Errorf("copy from: %w", err)
}

if source.Rows() == 0 {
    // the file was empty - nothing to load
}

Typed satisfies RowSource[D], so decoding and the layout of a COPY row can live in different layers: one hands out a RowSource, and the layer that owns the database decides which columns it lands in.

Without a database

Reader and Typed stand on their own β€” no COPY, no pgx, no []any. Ranging cannot carry an error out, so the loop stops at the first bad row and Err() reports it afterwards, the same shape as bufio.Scanner:

rows, err := csvcopy.NewTyped(file, (*clientRow).toClient)
if err != nil {
    return err
}

for client := range rows.All() {
    if err := send(ctx, client); err != nil {
        return err
    }
}
if err := rows.Err(); err != nil {
    return err
}

When no decoding is wanted either, Reader yields raw records:

reader, err := csvcopy.NewReader(file)
if err != nil {
    return err
}

fmt.Println(reader.Columns())

for record := range reader.All() {
    fmt.Println(record) // reused between iterations - copy it to keep it
}
return reader.Err()

βΈ»

πŸ— API

Layers
Constructor Returns Use when
NewReader(r, opts...) *Reader β€” header + row-by-row reading you only need CSV parsing, no COPY
NewRaw(r, opts...) *Raw β€” pgx.CopyFromSource + Columns() the shape comes from the file header
NewTyped[S, D](r, convert, opts...) *Typed[S, D] β€” RowSource[D] the shape comes from csv tags
NewCopy[T](src, columns, encode) *Copy[T] β€” pgx.CopyFromSource adapting any RowSource[T] to COPY
Interfaces
// RowSource is anything that yields values one at a time. Typed satisfies it,
// and so can a source of your own over XLSX, an API or a generator.
type RowSource[T any] interface {
    Next() bool
    Value() T
    Err() error
}
Options
Option Default Effect
WithComma(r rune) ';' field delimiter
WithLazyQuotes(bool) true tolerate a bare quote inside a field instead of failing
WithTrimLeadingSpace(bool) true drop white space at the start of a field
WithTrimValues(bool) true TrimSpace every value
WithHeaderRow(n uint) 1 which row holds the header; rows above it are dropped
WithNormalizeHeader(fn) collapse whitespace normalize a column name before matching
WithTag(string) "csv" which struct tag Typed reads column names from
WithVariableColumns(bool) false accept rows of a different width: missing trailing values β†’ nil, extra ones dropped
WithAllowMissingColumns(bool) false do not fail when a tagged column is absent from the header
WithPointerValues(bool) false Raw yields *string instead of string, removing one allocation per cell
Diagnostics
Method Gives
All() a range-able iterator: []string on Reader, []any on Raw, D on Typed
Err() the error that stopped the stream; end of file is not one
Columns() / Header() the header as read and normalized
Record() the raw record last read, for error messages
Unused() header columns no tag bound to
Line() the 1-based line number of the current row
Rows() how many rows have been handed out

Unused() is worth logging as a warning: when an export renames a column, the tag simply matches nothing, no error is raised, and the wrong data reaches the database. The unbound column is the only visible trace.

⚠️ WithAllowMissingColumns(true) is dangerous. The absent field reads as the empty string on every row and reaches the database as NULL, quietly wiping whatever that column held. Only use it where that is acceptable.

Errors

Errors are split by who can fix them.

Sentinel Wraps ErrParse Cause
ErrParse β€” a malformed record, a failing convert, a read failure
ErrMissingColumns yes the header lacks a column a tag asks for
ErrSchema no a bug in the calling code: not a struct, a tag on a non-string or unexported field, a nil reader or convert, an unusable delimiter

Everything a file can cause wraps ErrParse and carries the line number:

csv parse: line 4213: record on line 4213: wrong number of fields

If your application already has a sentinel for a bad file, alias it and every existing errors.Is check keeps working:

var ErrBadFile = csvcopy.ErrParse

ErrSchema deliberately does not wrap ErrParse: no input file will ever fix it, so quarantine-the-file logic must not fire on it. It is a code bug, and it surfaces on the very first run β€” including a run on an empty file.

βΈ»

πŸ“ Semantics and guarantees

  • Empty input (zero bytes, or only a BOM) is not an error: Columns() == nil, Next() == false, Err() == nil, Rows() == 0.
  • The BOM (EF BB BF) is stripped, read with io.ReadFull so that a short read from a slow io.Reader β€” which the contract allows β€” cannot leave a ο»Ώ in the first column name.
  • The first bad row stops the stream, for good. Rows are never skipped: a partly loaded table is worse than a failed load. The error is available from Err(), and it stays there β€” a source that has failed yields nothing more, so ranging All() a second time cannot resume past the bad row. Breaking out of a loop is different: that is not an error, and the next pull carries on.
  • Record() names the row that failed, as far as encoding/csv got with it. Line() names its number.
  • Memory is constant and independent of the row count. Values() reuses one slice, which is safe under pgx.CopyFrom because it encodes a row before asking for the next. If you drive a source by hand, do not retain the result of Values() between iterations.
  • Not safe for concurrent use. One source, one goroutine β€” the same as pgx.CopyFrom.
  • Reader, Raw and Typed do not close the io.Reader you give them.

βΈ»

⚑ Performance

Live memory is constant, but allocations over a pass grow linearly, and it is worth knowing where from. 100k rows of 5 columns, go1.26.1, Ryzen 5 7500F:

ns/op B/op allocs/op per row
Typed 14.4 ms 3.2 MB 100 038 1
Typed via All() 14.0 ms 3.2 MB 100 038 1
Raw 18.6 ms 11.2 MB 600 033 6
Raw via All() 18.6 ms 11.2 MB 600 033 6
Raw + WithPointerValues 10.9 ms 3.2 MB 100 033 1

Ranging costs nothing: an iter.Seq returned from a method closes over the source once per pass, not once per row, so All() sits on the same allocation count as the Next() loop it replaces.

The one allocation per row is encoding/csv: it creates one string per record even with ReuseRecord, and that is the floor short of unsafe.

The other five are the columns. Boxing a string into an any always allocates 16 bytes for its header, so Raw pays one allocation per cell by default β€” that is the shape of Values() ([]any, error). WithPointerValues(true) yields *string out of an array allocated once per file: a pointer is pointer-shaped, the interface holds it directly, and boxing is free. On a 30-column file the difference is thirtyfold.

The option is off by default: pgx dereferences *T through its pointer encode plan and *string is the ordinary way to pass a nullable text value, but this has not been verified against a real Postgres. Turn it on deliberately and check the loaded result.

In Copy the boxing happens inside your own encode, so the option does not affect it.

βΈ»

πŸ”§ Fitting it into existing code

Both layers rely on structural typing, so the package usually drops in without reshaping the caller:

  • If you already have your own row-source interface with Next/Value/Err, it can be replaced by csvcopy.RowSource[T] β€” the method sets match and the implementations do not change.
  • If another format is read alongside CSV (XLSX, a stream from an API), it is enough for its source to implement the same Next/Values/Err. Both branches stay pgx.CopyFromSource, and choosing a format comes down to returning different values of one interface.
  • Your own sentinel for a bad file becomes an alias of csvcopy.ErrParse, and errors.Is keeps working.

βΈ»

πŸ” Alternatives

Package License Streaming Shape from header CopyFromSource
csvcopy MIT yes, pull yes yes
jszwec/csvutil MIT yes, pull no no
gocarina/gocsv MIT yes, push no no
artonge/go-csv-tag GPL-3.0 no, ReadAll no no

The difference between pull and push matters here. pgx.CopyFrom pulls: it calls Next(), encodes the row into its buffer, and only then asks for the next one. Packages like gocsv push β€” UnmarshalToChan(in io.Reader, c interface{}) and UnmarshalToCallback(in io.Reader, f interface{}) drive the loop themselves. Connecting such a source to CopyFrom needs a goroutine and a channel between them, which means concurrency, backpressure and carrying an error across a goroutine boundary, all inside an open transaction. A pull interface of three methods needs none of that.

If you only need CSV decoded into structs and no COPY FROM, use csvutil: it is more mature and does more (inline, omitempty, custom unmarshalers). csvcopy solves the narrower problem of loading into PostgreSQL, which is why it has a layer that takes its shape from the file header and adapters for CopyFrom, and why it deliberately does no type conversion β€” that belongs to the caller, who needs to tell "empty β†’ NULL" from "zero β†’ 0".

βΈ»

πŸ“„ License

MIT.

Documentation ΒΆ

Overview ΒΆ

Package csvcopy streams CSV one row at a time.

A row is read, handed over and forgotten, so memory does not depend on the size of the file. There are no dependencies outside the standard library.

Bulk-loading into PostgreSQL is what it is shaped for, and the reason the row sources expose Next/Values/Err: that method set is pgx.CopyFromSource, satisfied structurally rather than by importing pgx, so the version of pgx stays the application's choice. Nothing here requires a database, though - Reader is a plain CSV reader, and Typed decodes into your own types. Both have All for ranging.

Layers ΒΆ

Two ways to fix the shape of a file, because both turn up in practice.

Raw takes the shape from the file's own header - whatever columns arrived, in their order, all as text. This is the staging table case, where a SQL script types the data afterwards:

src, err := csvcopy.NewRaw(file)
if err != nil {
	return err
}
if len(src.Columns()) == 0 {
	return nil // empty file, nothing to load
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{table}, src.Columns(), src)

Typed takes the shape from struct tags. Columns are matched by name, so the file may reorder them or add new ones; a column a tag asks for and the file lacks is an error. Every tagged field is a string, and convert turns the row into whatever the program actually works with - only the caller can tell an empty cell from a zero:

type row struct {
	ID   string `csv:"id"`
	Name string `csv:"name"`
}

rows, err := csvcopy.NewTyped(file, (*row).toEntity)
if err != nil {
	return err
}

source := csvcopy.NewCopy(rows, len(columns), func(dst []any, e *entity) []any {
	return append(dst, e.ID, e.Name)
})
n, err := tx.CopyFrom(ctx, pgx.Identifier{table}, columns, source)

NewCopy adapts any RowSource - Typed, or one of your own over XLSX or an API - to pgx.CopyFromSource.

Without a database ΒΆ

Reader and Typed stand on their own. Ranging stops at the first bad row and Err reports it afterwards, the same shape as bufio.Scanner:

rows, err := csvcopy.NewTyped(file, (*row).toEntity)
if err != nil {
	return err
}

for entity := range rows.All() {
	send(entity)
}
if err := rows.Err(); err != nil {
	return err
}

Reader.All yields raw records as []string, for when no decoding is wanted either.

Guarantees ΒΆ

An empty input is not an error: no columns, no rows, no error. The caller decides what that means.

A UTF-8 BOM is stripped, read with io.ReadFull so a slow reader cannot leave it in place.

The first bad row stops the stream for good. Rows are not skipped: a partly loaded table is worse than a failed load. The error is available from Err, names the line, and stays there - a source that has failed yields nothing more, so ranging All again cannot resume past the row that broke. Breaking out of a loop is not an error, and the next pull carries on from where it stopped.

Record and Line name the row that failed, so an error message can carry it.

Errors a file can cause wrap ErrParse, so an application can alias its own sentinel to it. Errors the calling code causes wrap ErrSchema instead, because no file will fix them.

Values reuses one slice between rows. That is safe under pgx.CopyFrom, which encodes a row before asking for the next; a caller driving a source by hand must not retain it.

Nothing here is safe for concurrent use, and neither is pgx.CopyFrom.

Index ΒΆ

Examples ΒΆ

Constants ΒΆ

This section is empty.

Variables ΒΆ

View Source
var ErrMissingColumns = fmt.Errorf("%w: header is missing columns", ErrParse)

ErrMissingColumns reports columns that a csv tag asked for and the header does not have.

This is fatal rather than a warning: a field bound to nothing would read as the empty string on every row and reach the database as NULL, quietly wiping the column it was supposed to fill. WithAllowMissingColumns lifts the check for callers who accept that risk.

View Source
var ErrParse = errors.New("csv parse")

ErrParse is wrapped by every error that a file can cause: a malformed record, a header that does not match the struct, a failing convert. An application can therefore alias its own sentinel to it and keep its existing errors.Is checks working:

var ErrBadFile = csvcopy.ErrParse
View Source
var ErrSchema = errors.New("csv schema")

ErrSchema reports wiring that cannot work whatever the file holds: a struct that cannot be decoded into at all - not a struct, a tagged field that is not a string, a tagged field that is not exported - a nil reader or convert, or a delimiter encoding/csv will not accept.

It deliberately does not wrap ErrParse. No input file will ever fix it, so a caller that retries or quarantines files on ErrParse should not treat it as a bad file - it is a bug in the calling code.

Functions ΒΆ

func NormalizeSpace ΒΆ

func NormalizeSpace(s string) string

NormalizeSpace is the default header normalizer: it collapses every run of whitespace into a single space and trims the ends.

Exported column names arrive wrapped across lines or padded for alignment, so "date of\n birth" and "date of birth" have to resolve to the same column.

Types ΒΆ

type Copy ΒΆ

type Copy[T any] struct {
	// contains filtered or unexported fields
}

Copy adapts a RowSource to pgx.CopyFromSource.

Only the mapping of a value onto its columns lives here; where the values came from is none of this layer's business.

func NewCopy ΒΆ

func NewCopy[T any](src RowSource[T], columns int, encode func(dst []any, item T) []any) *Copy[T]

NewCopy wraps src, laying each value out with encode.

columns is how many values encode appends; it only sizes the backing slice, so being wrong costs an allocation rather than correctness. encode must append in the same order as the column list passed to pgx.CopyFrom - the two are a pair, and Postgres cannot notice when they disagree if the types are compatible.

Panics if src or encode is nil: both are wiring, and a nil one cannot be recovered from at the point it would be noticed.

func (*Copy[T]) Err ΒΆ

func (c *Copy[T]) Err() error

Err reports the underlying source's error.

func (*Copy[T]) Next ΒΆ

func (c *Copy[T]) Next() bool

Next advances the underlying source.

func (*Copy[T]) Rows ΒΆ

func (c *Copy[T]) Rows() int64

Rows is the number of rows handed out so far. Counted here rather than asked of the source, which need not track it.

func (*Copy[T]) Values ΒΆ

func (c *Copy[T]) Values() ([]any, error)

Values lays out the current value.

The result is stored back, so a row wider than columns grows the slice once instead of reallocating on every row. Safe to reuse because pgx encodes the row before pulling the next one.

type Option ΒΆ

type Option func(*settings)

Option configures a Reader and everything built on top of it.

func WithAllowMissingColumns ΒΆ

func WithAllowMissingColumns(allow bool) Option

WithAllowMissingColumns downgrades a missing tagged column from an error to silence.

Dangerous: the field will read as empty on every row and reach the database as NULL, wiping whatever that column held. Only use it where that is acceptable.

func WithComma ΒΆ

func WithComma(comma rune) Option

WithComma sets the field delimiter. Defaults to ';'.

A quote, a carriage return, a newline and an invalid rune cannot delimit anything encoding/csv is willing to read, so the constructor rejects them with ErrSchema rather than letting the first row fail with ErrParse.

func WithHeaderRow ΒΆ

func WithHeaderRow(row uint) Option

WithHeaderRow sets which row holds the header, counting from 1. Rows before it are read and dropped, so a file may carry a title or a note above its table. Defaults to 1.

func WithLazyQuotes ΒΆ

func WithLazyQuotes(lazy bool) Option

WithLazyQuotes allows a bare quote inside an unquoted field instead of failing the record. Defaults to true.

func WithNormalizeHeader ΒΆ

func WithNormalizeHeader(fn func(string) string) Option

WithNormalizeHeader replaces the header normalizer. Passing nil restores identity. Defaults to NormalizeSpace.

func WithPointerValues ΒΆ

func WithPointerValues(pointers bool) Option

WithPointerValues makes Raw hand out *string instead of string, which removes one allocation per column per row.

Putting a string into an []any boxes it, and boxing a string always allocates 16 bytes for its header - so the default costs one allocation per cell, which on a wide file dwarfs everything else. A pointer is pointer-shaped: the interface holds it directly and boxing is free. The strings live in one array allocated per file, and a value the row stopped short of is a nil interface, still NULL.

Off by default on purpose. pgx dereferences *T through its pointer encode plan and *string is the ordinary way to pass a nullable text value, so this should be transparent - but "should" is not "measured against a real database". Turn it on, load a real file, compare the result, then make it the default.

Affects Raw only. In Copy the boxing happens inside your own encode func.

func WithTag ΒΆ

func WithTag(tag string) Option

WithTag sets the struct tag Typed reads column names from. Defaults to "csv".

func WithTrimLeadingSpace ΒΆ

func WithTrimLeadingSpace(trim bool) Option

WithTrimLeadingSpace drops white space at the start of a field. Defaults to true.

func WithTrimValues ΒΆ

func WithTrimValues(trim bool) Option

WithTrimValues applies strings.TrimSpace to every value. Defaults to true.

func WithVariableColumns ΒΆ

func WithVariableColumns(variable bool) Option

WithVariableColumns accepts rows whose field count differs from the header's. Missing trailing values become NULL and extra ones are dropped.

Off by default: a row that is the wrong width usually means the delimiter or the quoting is misread, and failing beats loading shifted data.

type Raw ΒΆ

type Raw struct {
	// contains filtered or unexported fields
}

Raw streams a file whose shape is only known from its own header.

Every column arrives as text, in the file's order, under the file's names - the shape of a staging table that a SQL script types afterwards. Nothing here knows or cares what the columns mean.

It satisfies pgx.CopyFromSource. That interface is not imported: Go interfaces are structural, so the method set alone is enough and the version of pgx stays the application's choice.

func NewRaw ΒΆ

func NewRaw(r io.Reader, opts ...Option) (*Raw, error)

NewRaw reads the header and prepares a source over the rows after it.

func (*Raw) All ΒΆ

func (s *Raw) All() iter.Seq[[]any]

All iterates the rows, for driving the source by hand rather than handing it to pgx.CopyFrom.

Stops at the first bad row; Err reports it afterwards. The yielded slice is reused by the next iteration.

If you want the values as text rather than as []any, range Reader.All instead - there is no reason to go through the boxing.

func (*Raw) Columns ΒΆ

func (s *Raw) Columns() []string

Columns is the column list to pass to pgx.CopyFrom, taken from the header.

Empty when the file was empty - check it before creating a table, there is nothing to load.

func (*Raw) Err ΒΆ

func (s *Raw) Err() error

Err returns the error that stopped the stream, if any. Check it before the error pgx.CopyFrom returns: this one names the line.

func (*Raw) Line ΒΆ

func (s *Raw) Line() int

Line is the 1-based line number of the current row.

func (*Raw) Next ΒΆ

func (s *Raw) Next() bool

Next advances to the next row, stopping at the end of the file or at the first malformed record.

func (*Raw) Record ΒΆ

func (s *Raw) Record() []string

Record returns the raw record behind the current row, for error messages.

func (*Raw) Rows ΒΆ

func (s *Raw) Rows() int64

Rows is the number of rows handed out so far.

func (*Raw) Values ΒΆ

func (s *Raw) Values() ([]any, error)

Values returns the current row.

The slice is reused between rows, which is safe for pgx.CopyFrom because it encodes each row before asking for the next. A caller driving the source by hand must not hold on to it.

type Reader ΒΆ

type Reader struct {
	// contains filtered or unexported fields
}

Reader is the bottom layer both sources are built on: it strips a UTF-8 BOM, configures encoding/csv, and reads the header.

It reads one record at a time and never holds more than the current one, which is what keeps the whole pipeline to a constant amount of memory no matter how large the file is. It does not close the underlying io.Reader.

func NewReader ΒΆ

func NewReader(r io.Reader, opts ...Option) (*Reader, error)

NewReader reads the header and prepares the reader for row-by-row use.

An empty input is not an error: Columns returns nil and Read returns io.EOF at once. What an empty file means is the caller's decision, not this package's.

func (*Reader) All ΒΆ

func (r *Reader) All() iter.Seq[[]string]

All iterates the records left in the file.

Ranging cannot carry an error out, so the loop stops at the first bad record and Err reports it afterwards - the same shape as bufio.Scanner:

for record := range reader.All() {
	...
}
if err := reader.Err(); err != nil {
	return err
}

Breaking out of the loop leaves the reader usable, and the next pull picks up where the range left off. An error does not: the reader is done, and ranging it again yields nothing rather than resuming past the record that failed.

The yielded slice is reused by the next iteration. Copy it if it has to outlive one turn of the loop.

func (*Reader) Columns ΒΆ

func (r *Reader) Columns() []string

Columns returns the header as it was read and normalized, or nil if the input was empty. The slice is shared, not copied - it is handed straight to pgx.CopyFrom, which does not modify it.

func (*Reader) Err ΒΆ

func (r *Reader) Err() error

Err returns the error that stopped reading, if any. End of file is not one.

func (*Reader) Line ΒΆ

func (r *Reader) Line() int

Line is the 1-based number of the record the reader is on, counting the header. After a failed Read it names the record that failed.

func (*Reader) Read ΒΆ

func (r *Reader) Read() ([]string, error)

Read returns the next record, or io.EOF when there are none left.

The first bad record ends the reader: the error is returned again by every later call and stays in Err. Rows are never skipped, so a caller cannot resume past a bad record and mistake a truncated file for a whole one.

The returned slice is reused by the next call. Every error other than io.EOF wraps ErrParse and carries the line number.

func (*Reader) Record ΒΆ

func (r *Reader) Record() []string

Record returns the record last read, for error messages that need the offending row rather than just its number.

After a failed Read it holds that record as far as encoding/csv got with it, or nil where it could not produce one at all.

Only valid until the next Read: the backing slice is reused.

type RowSource ΒΆ

type RowSource[T any] interface {
	Next() bool
	Value() T
	Err() error
}

RowSource is anything that yields values one at a time: Typed, or a source of your own over XLSX, an API or a generator.

The interface is a pull, not a push, and that is the point. pgx.CopyFrom drives the loop itself - it asks for a row, encodes it into its send buffer, and only then asks for the next one. A source that pushes rows into a channel or a callback would need a goroutine between it and CopyFrom, and with it error handling across a goroutine boundary inside an open transaction.

type Typed ΒΆ

type Typed[S any, D any] struct {
	// contains filtered or unexported fields
}

Typed streams a file whose shape is fixed by a struct.

S is the row as it appears in the file - every field a string, tagged with the column name it comes from. D is what the rest of the program works with, produced by convert. The split is deliberate: only the caller can decide whether an empty cell is a NULL or a zero, and whether a bad value fails the file or the field, so this package never converts anything itself.

Typed satisfies RowSource[D], so decoding and the layout of a COPY row can live in different layers: one hands out a RowSource, and the layer that owns the database decides which columns it lands in.

func NewTyped ΒΆ

func NewTyped[S any, D any](r io.Reader, convert func(*S) (D, error), opts ...Option) (*Typed[S, D], error)

NewTyped reads the header, binds it to S's tags and prepares a source over the rows after it.

convert is called once per row with a pointer to a struct that is reused for the whole file, so it must not keep that pointer. Returning an error from it stops the stream, and the error is reported with the line it came from.

Only the fields a column bound to are written before each call. A field no tag asked for - untagged, or tagged "-" - is never touched, so whatever convert leaves in one it will see again on the next row.

Example ΒΆ
const file = "name;id\nAlice;1\nBob;2\n"

rows, err := NewTyped(strings.NewReader(file), toEntity)
if err != nil {
	panic(err)
}

source := NewCopy(rows, 2, func(dst []any, e *entity) []any {
	return append(dst, e.ID, e.Name)
})

// tx.CopyFrom(ctx, pgx.Identifier{"people"}, []string{"id", "name"}, source)
for source.Next() {
	values, _ := source.Values()
	fmt.Println(values...)
}
if err = source.Err(); err != nil {
	panic(err)
}
Output:
1 Alice
2 Bob

func (*Typed[S, D]) All ΒΆ

func (s *Typed[S, D]) All() iter.Seq[D]

All iterates the decoded rows.

This is the layer that is useful with no database in sight: CSV in, your own type out, one row at a time. Stops at the first row that fails to parse or convert, and Err reports it afterwards:

for client := range rows.All() {
	...
}
if err := rows.Err(); err != nil {
	return err
}

Unlike the other All methods, what is yielded here is whatever convert returned, so nothing is reused behind your back.

func (*Typed[S, D]) Err ΒΆ

func (s *Typed[S, D]) Err() error

Err returns the error that stopped the stream, if any.

func (*Typed[S, D]) Header ΒΆ

func (s *Typed[S, D]) Header() []string

Header returns the header as read and normalized, or nil if the file was empty.

func (*Typed[S, D]) Line ΒΆ

func (s *Typed[S, D]) Line() int

Line is the 1-based line number of the current row.

func (*Typed[S, D]) Next ΒΆ

func (s *Typed[S, D]) Next() bool

Next advances to the next row, stopping at the end of the file, at the first malformed record, or at the first row convert rejects.

func (*Typed[S, D]) Record ΒΆ

func (s *Typed[S, D]) Record() []string

Record returns the raw record behind the current row, for error messages.

func (*Typed[S, D]) Rows ΒΆ

func (s *Typed[S, D]) Rows() int64

Rows is the number of rows handed out so far. Zero after a full pass means the file held nothing, which is not in itself an error.

func (*Typed[S, D]) Unused ΒΆ

func (s *Typed[S, D]) Unused() []string

Unused returns the header columns no tag bound to.

Worth logging as a warning: when an export renames a column, the tag stops matching it, no error is raised, and the column that was renamed away is the only trace left.

func (*Typed[S, D]) Value ΒΆ

func (s *Typed[S, D]) Value() D

Value returns the row Next produced. Only meaningful after Next returned true.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL