Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/content/manual/dev/manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ sections:
like awk's -f option. This changes the filter argument to be
interpreted as a filename, instead of the source of a program.

* `-o` / `--output-file filename`:

Write output values to the named file instead of standard out.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the rename-into-place or truncate-and-rewrite semantics? I think that needs to be stated clearly, though it's true that many tools that have -o FILE options don't say so clearly.


The outputs from `--debug-dump-disasm` and `--debug-trace` are
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe these should always have gone to stderr though, no? I would be for making that change.

still written to standard out, so this option can be used to
separate them.

* `-L directory` / `--library-path directory`:

Prepend `directory` to the search list for modules. If this
Expand Down
11 changes: 10 additions & 1 deletion jq.1.prebuilt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 66 additions & 31 deletions src/main.c
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
#include <assert.h>
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <libgen.h>
#ifdef HAVE_SETLOCALE
#include <locale.h>
#endif
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

#ifdef WIN32
Expand Down Expand Up @@ -92,6 +94,7 @@ static void usage(int code, int keep_it_short) {
" an array;\n"
" --seq parse input/output as application/json-seq;\n"
" -f, --from-file load the filter from a file;\n"
" -o, --output-file file output to file instead of stdout;\n"
" -L, --library-path dir search modules from the directory;\n"
" --arg name value set $name to the string value;\n"
" --argjson name value set $name to the JSON value;\n"
Expand Down Expand Up @@ -143,22 +146,24 @@ static int isoption(const char** text, char shortopt, const char* longopt, int i
}

enum {
SLURP = 1,
RAW_INPUT = 2,
PROVIDE_NULL = 4,
RAW_OUTPUT = 8,
RAW_OUTPUT0 = 16,
ASCII_OUTPUT = 32,
COLOR_OUTPUT = 64,
NO_COLOR_OUTPUT = 128,
SORTED_OUTPUT = 256,
FROM_FILE = 512,
RAW_NO_LF = 1024,
UNBUFFERED_OUTPUT = 2048,
EXIT_STATUS = 4096,
SEQ = 16384,
/* debugging only */
DUMP_DISASM = 32768,
SLURP = 1 << 0,
RAW_INPUT = 1 << 1,
PROVIDE_NULL = 1 << 2,
RAW_OUTPUT = 1 << 3,
RAW_OUTPUT0 = 1 << 4,
ASCII_OUTPUT = 1 << 5,
COLOR_OUTPUT = 1 << 6,
NO_COLOR_OUTPUT = 1 << 7,
SORTED_OUTPUT = 1 << 8,
FROM_FILE = 1 << 9,
RAW_NO_LF = 1 << 10,
UNBUFFERED_OUTPUT = 1 << 11,
EXIT_STATUS = 1 << 12,
SEQ = 1 << 13,
DUMP_DISASM = 1 << 14, /* debugging only */
#ifdef WIN32
BINARY_OUTPUT = 1 << 15,
Comment thread
thaliaarchi marked this conversation as resolved.
#endif
};

enum {
Expand All @@ -172,22 +177,22 @@ enum {
#define jq_exit_with_status(r) exit(abs(r))
#define jq_exit(r) exit( r > 0 ? r : 0 )

static int process(jq_state *jq, jv value, int flags, int dumpopts, int options) {
static int process(jq_state *jq, jv value, FILE *ofile, int flags, int dumpopts, int options) {
int ret = JQ_OK_NO_OUTPUT; // No valid results && -e -> exit(4)
jq_start(jq, value, flags);
jv result;
while (jv_is_valid(result = jq_next(jq))) {
if ((options & RAW_OUTPUT) && jv_get_kind(result) == JV_KIND_STRING) {
if (options & ASCII_OUTPUT) {
jv_dumpf(jv_copy(result), stdout, JV_PRINT_ASCII);
jv_dumpf(jv_copy(result), ofile, JV_PRINT_ASCII);
} else if ((options & RAW_OUTPUT0) && strlen(jv_string_value(result)) != (unsigned long)jv_string_length_bytes(jv_copy(result))) {
jv_free(result);
result = jv_invalid_with_msg(jv_string(
"Cannot dump a string containing NUL with --raw-output0 option"));
break;
} else {
priv_fwrite(jv_string_value(result), jv_string_length_bytes(jv_copy(result)),
stdout, dumpopts & JV_PRINT_ISATTY);
ofile, dumpopts & JV_PRINT_ISATTY);
}
ret = JQ_OK;
jv_free(result);
Expand All @@ -197,15 +202,15 @@ static int process(jq_state *jq, jv value, int flags, int dumpopts, int options)
else
ret = JQ_OK;
if (options & SEQ)
priv_fwrite("\036", 1, stdout, dumpopts & JV_PRINT_ISATTY);
jv_dump(result, dumpopts);
priv_fwrite("\036", 1, ofile, dumpopts & JV_PRINT_ISATTY);
jv_dumpf(result, ofile, dumpopts);
}
if (!(options & RAW_NO_LF))
priv_fwrite("\n", 1, stdout, dumpopts & JV_PRINT_ISATTY);
priv_fwrite("\n", 1, ofile, dumpopts & JV_PRINT_ISATTY);
if (options & RAW_OUTPUT0)
priv_fwrite("\0", 1, stdout, dumpopts & JV_PRINT_ISATTY);
priv_fwrite("\0", 1, ofile, dumpopts & JV_PRINT_ISATTY);
if (options & UNBUFFERED_OUTPUT)
fflush(stdout);
fflush(ofile);
}
if (jq_halted(jq)) {
// jq program invoked `halt` or `halt_error`
Expand Down Expand Up @@ -294,6 +299,7 @@ int main(int argc, char* argv[]) {
int compiled = 0;
int parser_flags = 0;
int nfiles = 0;
FILE *ofile = stdout;
int last_result = -1; /* -1 = no result, 0=null or false, 1=true */
int badwrite;
int options = 0;
Expand Down Expand Up @@ -404,6 +410,23 @@ int main(int argc, char* argv[]) {
options |= PROVIDE_NULL;
} else if (isoption(&text, 'f', "from-file", is_short)) {
options |= FROM_FILE;
} else if (isoption(&text, 'o', "output-file", is_short)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what should happen if output file arg is used more than once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file from the first flag is opened, then closed when the second flag is parsed. I think this is fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Each file will be truncated, and only the last one will be written to (after being truncated). This seems like a footgun.

Copy link
Copy Markdown

@christf christf Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should the user interface be in this case? Should jq -o a -o b . inputfile be allowed?
Writing multiple -o options feels odd to me.

In any case, it seems to me that the fopen/fclose bit needs to move to after all options have been parsed into the proximity of line 565 - because even if it should be allowed to specify -o only once, the code could catch a violation of that rule only after the first output file has already been truncated, which would potentially truncate inputs.
In case a temporary file is used as output to be renamed afterwards, not moving the fopen() till after parsing all options would leave temporary files in the file system, so I think the fopen needs to move in all cases and I had not considered that in my original commit.

options |= NO_COLOR_OUTPUT;
if (i >= argc - 1) {
fprintf(stderr, "jq: --output-file takes one parameter (e.g. --output-file filename)\n");
die();
}
if (ofile != stdout)
fclose(ofile);
ofile = fopen(argv[i+1], "w");
if (!ofile) {
fprintf(stderr, "jq: Could not open --output-file %s: %s\n", argv[i+1], strerror(errno));
die();
}
#ifdef WIN32
_setmode(fileno(ofile), options & BINARY_OUTPUT ? _O_BINARY : _O_TEXT | _O_U8TEXT);
Comment thread
thaliaarchi marked this conversation as resolved.
#endif
i += 1; // skip the next argument
} else if (isoption(&text, 'L', "library-path", is_short)) {
if (jv_get_kind(lib_search_paths) == JV_KIND_NULL)
lib_search_paths = jv_array();
Expand All @@ -419,9 +442,14 @@ int main(int argc, char* argv[]) {
}
} else if (isoption(&text, 'b', "binary", is_short)) {
#ifdef WIN32
options |= BINARY_OUTPUT;
if (ofile != stdout) {
fflush(ofile);
_setmode(fileno(ofile), _O_BINARY);
Comment thread
thaliaarchi marked this conversation as resolved.
}
fflush(stdout);
fflush(stderr);
_setmode(fileno(stdin), _O_BINARY);
_setmode(fileno(stdin), _O_BINARY);
_setmode(fileno(stdout), _O_BINARY);
_setmode(fileno(stderr), _O_BINARY);
#endif
Expand Down Expand Up @@ -537,7 +565,7 @@ int main(int argc, char* argv[]) {
}

#ifdef USE_ISATTY
if (isatty(STDOUT_FILENO)) {
if (ofile == stdout && isatty(STDOUT_FILENO)) {
#ifndef WIN32
dumpopts |= JV_PRINT_ISATTY | JV_PRINT_COLOR;
#else
Expand Down Expand Up @@ -590,7 +618,7 @@ int main(int argc, char* argv[]) {
jq_set_attr(jq, jv_string("VERSION_DIR"), jv_string_fmt("%.*s-master", (int)(strchr(JQ_VERSION, '-') - JQ_VERSION), JQ_VERSION));

#ifdef USE_ISATTY
if (!program && !(options & FROM_FILE) && (!isatty(STDOUT_FILENO) || !isatty(STDIN_FILENO)))
if (!program && !(options & FROM_FILE) && (ofile != stdout || !isatty(STDOUT_FILENO) || !isatty(STDIN_FILENO)))
program = ".";
#endif

Expand Down Expand Up @@ -664,13 +692,13 @@ int main(int argc, char* argv[]) {
jq_util_input_add_input(input_state, "-");

if (options & PROVIDE_NULL) {
ret = process(jq, jv_null(), jq_flags, dumpopts, options);
ret = process(jq, jv_null(), ofile, jq_flags, dumpopts, options);
} else {
jv value;
while (jq_util_input_errors(input_state) == 0 &&
(jv_is_valid((value = jq_util_input_next_input(input_state))) || jv_invalid_has_msg(jv_copy(value)))) {
if (jv_is_valid(value)) {
ret = process(jq, value, jq_flags, dumpopts, options);
ret = process(jq, value, ofile, jq_flags, dumpopts, options);
if (ret <= 0 && ret != JQ_OK_NO_OUTPUT)
last_result = (ret != JQ_OK_NULL_KIND);
if (jq_halted(jq))
Expand All @@ -697,10 +725,17 @@ int main(int argc, char* argv[]) {

out:
badwrite = ferror(stdout);
if (fclose(stdout)!=0 || badwrite) {
fprintf(stderr,"jq: error: writing output failed: %s\n", strerror(errno));
if (fclose(stdout) != 0 || badwrite) {
fprintf(stderr, "jq: error: writing output failed: %s\n", strerror(errno));
ret = JQ_ERROR_SYSTEM;
}
if (ofile != stdout) {
badwrite = ferror(ofile);
if (fclose(ofile) != 0 || badwrite) {
fprintf(stderr, "jq: error: writing output failed: %s\n", strerror(errno));
ret = JQ_ERROR_SYSTEM;
}
}

jv_free(ARGS);
jv_free(program_arguments);
Expand Down
98 changes: 98 additions & 0 deletions tests/shtest
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,104 @@ printf '[\n {\n "a": 1\n }\n]\n' > $d/expected
$JQ --indent 6 -n "[{a:1}]" > $d/out
cmp $d/out $d/expected

# Simple --output-file
printf '{"wide":"👋"}' > $d/expected
$JQ -j -c -o $d/out -n '{wide:"👋"}'
cmp $d/out $d/expected

# --output-file creates a file
rm $d/out
echo '"create"' > $d/expected
$JQ -o $d/out -n '"create"'
cmp $d/out $d/expected

# Program errors are not written to --output-file
echo 42 > $d/expected
echo 'jq: error (at <unknown>): error!' > $d/expected_err
! $JQ -o $d/out -n '42, ("error!" | error)' 2> $d/err
cmp $d/out $d/expected
cmp $d/err $d/expected_err

# Disassembly is not written to --output-file
echo 42 > $d/expected
printf '0000 TOP\n0001 LOADK 42\n0003 RET\n\n' > $d/expected_disasm
$JQ --debug-dump-disasm -o $d/out -n 42 > $d/disasm
cmp $d/out $d/expected
cmp $d/disasm $d/expected_disasm

# Debug trace is not written to --output-file
echo 42 > $d/expected
printf '0000 TOP\t\n0001 LOADK 42\tnull\n0003 RET\t42\n0003 RET\t\t<backtracking>\n' > $d/expected_trace
$JQ --debug-trace -o $d/out -n 42 > $d/trace
cmp $d/out $d/expected
cmp $d/trace $d/expected_trace

# --output-file is one of the inputs
printf '1\n2\n' > $d/out
printf '' > $d/expected
$JQ -o $d/out '.+3' $d/out
cmp $d/out $d/expected

# --output-file with --from-file
echo hello > $d/out
printf '' > $d/expected
cat > $d/expected_err <<EOF
jq: error: Top-level program not given (try ".")
jq: 1 compile error
EOF
! $JQ -f -o $d/out $d/out 2> $d/err
diff $d/out $d/expected
diff $d/err $d/expected_err

# --output-file before and after --rawfile
printf hello > $d/out
echo '""' > $d/expected
$JQ -o $d/out --rawfile arg $d/out -n '$arg'
diff $d/out $d/expected
printf hello > $d/out
echo '"hello"' > $d/expected
$JQ --rawfile arg $d/out -o $d/out -n '$arg'
diff $d/out $d/expected

# --output-file before and after --slurpfile
printf '"hello"\n"world"' > $d/out
echo '[]' > $d/expected
$JQ -o $d/out --slurpfile arg $d/out -nc '$arg'
diff $d/out $d/expected
printf '"hello"\n"world"' > $d/out
echo '["hello","world"]' > $d/expected
$JQ --slurpfile arg $d/out -o $d/out -nc '$arg'
diff $d/out $d/expected

# --output-file before and after --binary
printf '1\n2\n' > $d/expected
$JQ_NO_B -o $d/out --binary -n '1,2'
cmp $d/out $d/expected
$JQ_NO_B --binary -o $d/out -n '1,2'
cmp $d/out $d/expected

# --output-file before and after --color-output
echo '"hello"' > $d/expected
$JQ -o $d/out --color-output -n '"hello"'
cmp $d/out $d/expected
$JQ --color-output -o $d/out -n '"hello"'
cmp $d/out $d/expected

# --output-file without a filter uses . as the filter
printf '[\n 1,\n 2\n]\n' > $d/expected
echo '[1,2]' | $JQ -o $d/out
cmp $d/out $d/expected

# --output-file handles failure
! $JQ -o $d/notfound/out -n . 2> $d/err
grep "jq: Could not open --output-file .*/notfound/out: No such file or directory" $d/err > /dev/null

# --output-file does not allow directories
mkdir $d/dir
! $JQ -o $d/dir -n . 2> $d/err
grep "jq: Could not open --output-file .*/dir" $d/err > /dev/null
rmdir $d/dir
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any concerns to think about if output file is one of the input files? will get truncated before reading? streaming mode?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can give a good experience for this, as the input files are opened after the output file is opened. Perhaps there's some way the file descriptors can be opened with copy-on-write semantics, so the input can still read the existing contents?

I should write a test for this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that opening the file for reading, then opening it again for writing and truncating it, will allow the read fd to read the original contents. But we open them in the other order and inputs are opened lazily, so I don't know how to do this without eagerly opening all inputs, since they could all alias with sym/hardlinks. I'm skeptical it's worth it. Thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap seems messy :( tiny bit worried someone will try to use this as a "--in-place" workaround and be disappointed :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just add --in-place. I wouldn't mind implementing that. No need to complicate this so much for a mistake.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about storing the output file path and lazily opening the file when the full output is ready to be written?

Copy link
Copy Markdown

@christf christf Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about storing the output file path and lazily opening the file when the full output is ready to be written?

This approach would mean all output must be kept in a buffer before it can be written. As such, streaming becomes impossible and the amount of data to be written is constrained by the amount of memory of the machine.I do not think there will be a great approach here. I can see these options

  • do not stream as @Pandapip1 has described (this has the above consequence)
  • write to a temporary file instead and rename the file after opening the last input file (yikes)
  • open all input files right at the start before opening the output as @thaliaarchi has described

I am not sure how much work it would be. also there is the workaround of sorting the input files in case writing to one of these is desired. It is an edge case. Maybe documenting it for now could be good enough?

Edit: In case this did not come through, I share @thaliaarchi s scepticism.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a big deal. In general I expect tools that support -o FILE to also support using the same file as an input. Think of sed -i -- -i means "in-place". This is very tricky stuff to get right.


if ! $msys && ! $mingw; then
# Test handling of timezones -- #2429, #2475
if ! r=$(TZ=Asia/Tokyo $JQ -rn '1731627341 | strflocaltime("%F %T %z %Z")') \
Expand Down