Skip to content

JSON ↔ CSV Converter — Java source

Convert a JSON array of objects to CSV and back. Handles quoted fields, embedded commas, newlines and escaped quotes (RFC 4180). 100% in-browser.

This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-csv — Java port
// =============================================================================
// Convert between JSON and RFC 4180 CSV in either direction:
//   • jsonToCsv — serialize a JSON document (object or array of objects) to CSV
//   • csvToJson — parse RFC 4180 CSV (with quoting) into a list of row maps
//
// Language: Java 17 — standard library only; minimal embedded JSON parser.
// Source: CosmoDev polyglot showcase port of json-csv,
//         ported from src/lib/csv.ts (the canonical, live TypeScript lib).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Pure and deterministic — depends only on its inputs. RFC 4180 quoting: any
// field containing a comma, double quote, carriage return, or line feed is
// wrapped in double quotes, and each embedded quote is doubled.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================

// Java's stdlib has no JSON support, so this file is self-contained: a small
// recursive-descent parser, mirroring the Rust sibling snippet. Objects use a
// LinkedHashMap so key order — which is observable (it determines CSV column
// order) — is preserved, exactly as in the canonical lib.

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class JsonCsv {

    private JsonCsv() {}

    // ---------------------------------------------------------------------------
    // JSON value tree
    // ---------------------------------------------------------------------------

    public sealed interface Json permits Null, Bool, Num, Str, Arr, Obj {}
    public record Null() implements Json {}
    public record Bool(boolean value) implements Json {}
    public record Num(double value) implements Json {}
    public record Str(String value) implements Json {}
    public record Arr(List<Json> items) implements Json {}
    /** Insertion-ordered; duplicates keep their first position. */
    public record Obj(LinkedHashMap<String, Json> fields) implements Json {}

    // ---------------------------------------------------------------------------
    // Minimal JSON parser
    // ---------------------------------------------------------------------------
    // Compact recursive-descent parser. Sufficient for any RFC 8259 document a
    // caller is likely to feed this tool.

    public static final class ParseException extends RuntimeException {
        public ParseException(String message) { super(message); }
    }

    private static final class Parser {
        private final String input;
        private int pos;

        Parser(String input) { this.input = input; }

        private void skipWs() {
            while (pos < input.length()) {
                char c = input.charAt(pos);
                if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++;
                else break;
            }
        }

        private char peek() {
            if (pos >= input.length()) throw new ParseException("unexpected end of input");
            return input.charAt(pos);
        }

        private Json parseValue() {
            skipWs();
            char c = peek();
            switch (c) {
                case '{': return parseObject();
                case '[': return parseArray();
                case '"': return new Str(parseString());
                case 't': case 'f': return parseBool();
                case 'n': return parseNull();
                default:
                    if (c == '-' || (c >= '0' && c <= '9')) return parseNumber();
                    throw new ParseException("unexpected character '" + c + "'");
            }
        }

        private Obj parseObject() {
            pos++; // {
            LinkedHashMap<String, Json> fields = new LinkedHashMap<>();
            skipWs();
            if (peek() == '}') {
                pos++;
                return new Obj(fields);
            }
            while (true) {
                skipWs();
                if (peek() != '"') throw new ParseException("expected string key in object");
                String key = parseString();
                skipWs();
                if (peek() != ':') throw new ParseException("expected ':' after object key");
                pos++;
                Json val = parseValue();
                // First occurrence of a key wins, matching JS object-literal semantics.
                fields.putIfAbsent(key, val);
                skipWs();
                char c = peek();
                if (c == ',') {
                    pos++;
                } else if (c == '}') {
                    pos++;
                    return new Obj(fields);
                } else {
                    throw new ParseException("expected ',' or '}' in object");
                }
            }
        }

        private Arr parseArray() {
            pos++; // [
            List<Json> items = new ArrayList<>();
            skipWs();
            if (peek() == ']') {
                pos++;
                return new Arr(items);
            }
            while (true) {
                items.add(parseValue());
                skipWs();
                char c = peek();
                if (c == ',') {
                    pos++;
                } else if (c == ']') {
                    pos++;
                    return new Arr(items);
                } else {
                    throw new ParseException("expected ',' or ']' in array");
                }
            }
        }

        private String parseString() {
            pos++; // opening quote
            StringBuilder out = new StringBuilder();
            while (pos < input.length()) {
                char c = input.charAt(pos++);
                if (c == '"') return out.toString();
                if (c == '\\') {
                    if (pos >= input.length()) throw new ParseException("trailing escape");
                    char e = input.charAt(pos++);
                    switch (e) {
                        case '"': out.append('"'); break;
                        case '\\': out.append('\\'); break;
                        case '/': out.append('/'); break;
                        case 'n': out.append('\n'); break;
                        case 't': out.append('\t'); break;
                        case 'r': out.append('\r'); break;
                        case 'b': out.append('\b'); break;
                        case 'f': out.append('\f'); break;
                        case 'u': out.appendCodePoint(parseCodepoint()); break;
                        default: throw new ParseException("bad escape \\" + e);
                    }
                } else {
                    out.append(c);
                }
            }
            throw new ParseException("unterminated string");
        }

        private int parseCodepoint() {
            if (pos + 4 > input.length()) throw new ParseException("short \\u escape");
            int code;
            try {
                code = Integer.parseInt(input.substring(pos, pos + 4), 16);
            } catch (NumberFormatException e) {
                throw new ParseException("bad \\u escape");
            }
            pos += 4;
            // UTF-16 surrogate pair handling.
            if (code >= 0xD800 && code <= 0xDBFF
                    && pos + 6 <= input.length()
                    && input.startsWith("\\u", pos)) {
                int lo;
                try {
                    lo = Integer.parseInt(input.substring(pos + 2, pos + 6), 16);
                } catch (NumberFormatException e) {
                    throw new ParseException("bad low surrogate");
                }
                if (lo >= 0xDC00 && lo <= 0xDFFF) {
                    code = 0x10000 + ((code - 0xD800) << 10) + (lo - 0xDC00);
                    pos += 6;
                }
            }
            if (Character.isValidCodePoint(code)) return code;
            throw new ParseException("invalid unicode codepoint");
        }

        private Json parseBool() {
            if (input.startsWith("true", pos)) {
                pos += 4;
                return new Bool(true);
            }
            if (input.startsWith("false", pos)) {
                pos += 5;
                return new Bool(false);
            }
            throw new ParseException("invalid literal");
        }

        private Json parseNull() {
            if (input.startsWith("null", pos)) {
                pos += 4;
                return new Null();
            }
            throw new ParseException("invalid literal");
        }

        private Json parseNumber() {
            int start = pos;
            if (pos < input.length() && input.charAt(pos) == '-') pos++;
            while (pos < input.length()) {
                char c = input.charAt(pos);
                if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') pos++;
                else break;
            }
            String s = input.substring(start, pos);
            try {
                return new Num(Double.parseDouble(s));
            } catch (NumberFormatException e) {
                throw new ParseException("bad number " + s);
            }
        }
    }

    /** Parse a JSON document into a {@link Json} value. Callers holding a raw
     *  JSON string enter here. */
    public static Json parseJson(String input) {
        Parser p = new Parser(input);
        Json v = p.parseValue();
        p.skipWs();
        if (p.pos != input.length()) {
            throw new ParseException("trailing data at byte " + p.pos);
        }
        return v;
    }

    // ---------------------------------------------------------------------------
    // JS-equivalent value semantics
    // ---------------------------------------------------------------------------
    // The canonical lib uses `typeof x === 'object'` and Object.keys(x), which in
    // JavaScript treat BOTH objects and arrays as "object" and expose array indices
    // as string keys ("0", "1", ...). We mirror that so degenerate inputs (e.g. an
    // array of arrays) produce byte-identical output to the TS.

    /** Object.keys parity: array indices as strings ("0", "1", ...), or the
     *  object's insertion-ordered keys. Primitives and null yield no keys. */
    private static List<String> keysOf(Json v) {
        if (v instanceof Arr a) {
            List<String> keys = new ArrayList<>(a.items().size());
            for (int i = 0; i < a.items().size(); i++) keys.add(Integer.toString(i));
            return keys;
        }
        if (v instanceof Obj o) return new ArrayList<>(o.fields().keySet());
        return List.of();
    }

    /** JS `obj[key]` parity: object lookup, or array element at a non-negative
     *  integer index. Returns null when absent (which renders as the empty field). */
    private static Json getField(Json v, String key) {
        if (v instanceof Obj o) {
            Json val = o.fields().get(key);
            return val; // may be null = absent
        }
        if (v instanceof Arr a) {
            try {
                int i = Integer.parseInt(key);
                if (i >= 0 && i < a.items().size()) return a.items().get(i);
            } catch (NumberFormatException ignored) {
                // non-numeric index -> absent
            }
        }
        return null;
    }

    /** Render a number the way JS String(number) does on common inputs: Java's
     *  Double.toString keeps a trailing ".0" on integral doubles ("30.0"), so we
     *  print integral values as integers ("30"). */
    private static String formatNumber(double n) {
        if (n == Math.floor(n) && Math.abs(n) < 1e16) return Long.toString((long) n);
        return Double.toString(n);
    }

    /** Coerce a JSON value to its display string, replicating JavaScript's
     *  String(): null -> "", booleans -> "true"/"false", numbers -> decimal form,
     *  arrays -> elements joined by "," (so a comma-bearing cell re-quotes), and
     *  objects -> "[object Object]". */
    private static String jsString(Json v) {
        if (v instanceof Null) return "";
        if (v instanceof Bool b) return b.value() ? "true" : "false";
        if (v instanceof Num n) return formatNumber(n.value());
        if (v instanceof Str s) return s.value();
        if (v instanceof Arr a) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < a.items().size(); i++) {
                if (i > 0) sb.append(',');
                sb.append(jsString(a.items().get(i)));
            }
            return sb.toString();
        }
        return "[object Object]"; // Obj
    }

    /** Quote a single CSV field per RFC 4180. */
    private static String csvEscape(Json v) {
        String s = jsString(v);
        boolean needsQuoting = false;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == ',' || c == '"' || c == '\n' || c == '\r') {
                needsQuoting = true;
                break;
            }
        }
        if (!needsQuoting) return s;
        StringBuilder sb = new StringBuilder("\"");
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '"') sb.append("\"\"");
            else sb.append(c);
        }
        return sb.append('"').toString();
    }

    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------

    /** One deserialized CSV record: an insertion-ordered header -> cell map. We
     *  use a LinkedHashMap rather than a HashMap so duplicate/empty headers
     *  survive round-trips, exactly as in the TS lib's `Record<string, string>`
     *  indexing (later duplicate headers overwrite earlier ones, JS-style). */
    public static final class CsvRow {
        private final LinkedHashMap<String, String> cells;

        CsvRow(LinkedHashMap<String, String> cells) { this.cells = cells; }

        /** JS `row[header]` parity: "" when the header is absent. */
        public String get(String header) { return cells.getOrDefault(header, ""); }

        public LinkedHashMap<String, String> cells() { return cells; }

        @Override public String toString() { return cells.toString(); }
    }

    /** Serialize a JSON document to CSV.
     *
     *  Accepts a single object or an array of objects. Returns null on invalid
     *  JSON, or when the document yields no object rows (and thus no column
     *  headers) — e.g. a bare array of primitives such as <code>[1, 2, 3]</code>. */
    public static String jsonToCsv(String text) {
        Json data;
        try {
            data = parseJson(text);
        } catch (ParseException e) {
            return null;
        }

        // A bare value is treated as a one-row table.
        List<Json> rows = data instanceof Arr a ? a.items() : List.of(data);

        // Header union across object-like rows, first-seen order, de-duplicated.
        List<String> headers = new ArrayList<>();
        LinkedHashMap<String, Boolean> seen = new LinkedHashMap<>();
        for (Json row : rows) {
            for (String k : keysOf(row)) {
                if (!seen.containsKey(k)) {
                    seen.put(k, Boolean.TRUE);
                    headers.add(k);
                }
            }
        }
        if (headers.isEmpty()) return null;

        // First line is the (escaped) header row; subsequent lines are the rows.
        List<String> lines = new ArrayList<>(rows.size() + 1);
        lines.add(String.join(",", headers.stream().map(h -> csvEscape(new Str(h))).toList()));
        for (Json row : rows) {
            // A non-object row (null, number, string) yields an empty line: every
            // header lookup on it returns null -> the empty field.
            List<String> cells = new ArrayList<>(headers.size());
            for (String h : headers) {
                Json val = getField(row, h);
                cells.add(csvEscape(val == null ? new Null() : val));
            }
            lines.add(String.join(",", cells));
        }
        return String.join("\n", lines);
    }

    /** Parse RFC 4180 CSV into a list of rows keyed by the first row.
     *
     *  Handles quoted fields, doubled-quote escapes, and embedded
     *  commas/newlines; bare carriage returns outside quotes are ignored.
     *  Returns an empty list for empty input, or for input that is only a
     *  header row. */
    public static List<CsvRow> csvToJson(String text) {
        // Single-pass character-state machine. `text` is indexed by position so
        // we can look one character ahead for the doubled-quote escape.
        List<List<String>> rows = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        List<String> row = new ArrayList<>();
        boolean inQuotes = false;
        int n = text.length();

        int i = 0;
        while (i < n) {
            char ch = text.charAt(i);
            if (inQuotes) {
                if (ch == '"') {
                    // Doubled quote -> one literal quote; lone quote -> close field.
                    if (i + 1 < n && text.charAt(i + 1) == '"') {
                        field.append('"');
                        i += 2;
                        continue;
                    }
                    inQuotes = false;
                } else {
                    field.append(ch);
                }
            } else if (ch == '"') {
                inQuotes = true;
            } else if (ch == ',') {
                row.add(field.toString());
                field.setLength(0);
            } else if (ch == '\n') {
                row.add(field.toString());
                rows.add(row);
                row = new ArrayList<>();
                field.setLength(0);
            } else if (ch != '\r') {
                field.append(ch);
            }
            i++;
        }

        // Flush a trailing row only when there is pending content. Input that
        // ended with a newline already flushed; this guard avoids an empty
        // final row.
        if (field.length() > 0 || !row.isEmpty()) {
            row.add(field.toString());
            rows.add(row);
        }

        if (rows.isEmpty()) return List.of();

        List<String> headers = rows.get(0);
        List<CsvRow> out = new ArrayList<>(rows.size() - 1);
        for (List<String> r : rows.subList(1, rows.size())) {
            LinkedHashMap<String, String> cells = new LinkedHashMap<>();
            for (int j = 0; j < headers.size(); j++) {
                cells.put(headers.get(j), j < r.size() ? r.get(j) : "");
            }
            out.add(new CsvRow(cells));
        }
        return out;
    }

    public static void main(String[] args) {
        // Small end-to-end demo so this file is runnable as a showcase.
        String raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]";
        String csv = jsonToCsv(raw);
        System.out.println(csv);
        for (CsvRow row : csvToJson(csv == null ? "" : csv)) {
            System.out.println(row);
        }
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →