If you’re falling foul of tooling/patterns/practices that abstract this away from you: you can read data with write operations more than you’d think.

A lot of data gets read before it is written either to validate write intentions or to do further processing: Let’s say you have an API which lets you update the status of a Rat to Deceased. You don’t need to validate this really - even if you update a rat that’s already dead, it can’t get any more dead. All states of rat can be immediately transitioned to Deceased without intermediate states. We can keep it simple and just let this write occur no matter what.

The issue is: what if you want to send a condolences email when this happens? It’s not ideal to spam a grieving rat owner with multiple of these, so we need to make sure the rat was previously in a state that requires commiserations when transitioning to Deceased before we send it (I’m glossing over various other idempotency concerns/patterns for this example, bear with me). A conditional update might not be necessary as the write operation is fairly safe no matter what, but it can give us some feedback that lets us read a fact from the system, if we write with a condition that the rat status must not be Deceased for the write to succeed most database engines will return some indication of records affected - a 0 here means it’s already dead or does not exist and we don’t need to do anything.

And mature database products have ways to go beyond this, so let’s cover some examples:

Postgres has great support for the RETURNING clause which lets you return select data from modified rows. We need to get the owner’s email address for any dead rat on update in order to use it:

UPDATE rats
SET status = 'Deceased'
WHERE id = :id
  AND status != 'Deceased'
RETURNING (
  SELECT email
  FROM owners
  WHERE owners.id = rats.owner_id
);

MongoDB supports returning documents pre/post modification with findOneAndUpdate (also delete) and conditional updates. Given a similar structure with a rats and owners collection you’d need two operations (which you could wrap in a transaction if consistency really mattered - multiple IO hits either way).

const result = await db.collection('rats').findOneAndUpdate(
  { _id: ObjectId('...'), status: { $ne: 'Deceased' } },
  { $set: { status: 'Deceased' } },
  { returnDocument: 'before' } // or 'after' - does not matter here
);

if (result) {
  const owner = await db.collection('owners').findOne(
    { _id: result.owner_id },
    { projection: { email: 1 } }
  );
}

But if you’re using a document store why aren’t you embedding/pre-joining data anyway (ignoring the nightmare scenario of updating a user email for this contrived example)?

const result = await db.collection('rats').findOneAndUpdate(
  { _id: ObjectId('...'), status: { $ne: 'Deceased' } },
  { $set: { status: 'Deceased' } },
  { returnDocument: 'after', projection: { 'owner.email': 1 } }
);

Similar for DynamoDB which is where I started to really think hard about using writes to read because DynamoDB charges for reads and writes separately and reads are eventually consistent by default and are charged at double cost if strongly consistent reads are requested - worth noting that writes are strongly consistent and do not cost extra to return data from them (which will be strongly consistent). At scale that certainly starts to matter if you can eliminate a whole operation. The capabilities here are very similar to MongoDB with a nuance - you can choose only to return modified values rather than the full document which only affects network/processing burden with no charging difference. The UpdateItem operation supports these as a ReturnValues parameter:

try {
  const result = await dynamodb.send(new UpdateItemCommand({
    TableName: 'rats',
    Key: { id: { S: '...' } },
    UpdateExpression: 'SET #status = :deceased',
    ConditionExpression: '#status <> :deceased',
    ExpressionAttributeNames: { '#status': 'status' },
    ExpressionAttributeValues: {
      ':deceased': { S: 'Deceased' }
    },
    ReturnValues: 'ALL_NEW' // Or ALL_OLD, again doesn't matter because email was not modified
  }));

  return result.Attributes.owner.M.email.S;
} catch (err) {
  if (err.name === 'ConditionalCheckFailedException') {
    // Rat was already dead - no biggie
    return null;
  }
  throw err;
}

So, avoid a few round trips to the database, read/write inconsistency or unnecessary locking and transactions. Don’t bother reading at all! Get clever with your writes and keep things nice and terse with less manual data manipulation or interrogation in application code.